skills/changed-file-validation/scripts/Invoke-ChangedFileValidation.ps1
|
#Requires -Version 5.1 <# .SYNOPSIS Validates the files collected in one changed-file batch. .DESCRIPTION The explicit entry point for changed-file validation. It runs the repository's own checks over each collected file that is supported, present, and not already covered by a receipt for exactly the bytes on disk, then records what was checked. A receipt names the validator, its version, its configuration identity, the SHA-256 of the content that was actually read, the outcome, the exit status, and the diagnostic locations. The file is hashed again once the validators finish: a receipt is always bound to the bytes it covered, so an edit made during or after validation leaves the entry Stale instead of inheriting the old result. Missing tools, refused input, and time-bound violations are recorded as unavailable, timed-out, or failed checks. None of them ever produces a verified entry. .PARAMETER Path The selected project root. .PARAMETER SessionId The batch to validate. .PARAMETER Force Revalidate entries whose receipt already covers the current content. .PARAMETER TimeoutSecond The wall-clock bound for a single validator child process. Parsing and static analysis run in an owned worker, so the bound covers them too. .PARAMETER ExecutionWaitSecond How long to wait for the session execution lock before giving up. One validation run per session executes at a time. .PARAMETER MaxFileKilobyte The input-size bound. A larger file is recorded as unavailable and is never read or hashed. .PARAMETER MaxFileCount The bound on how many files one run validates. .PARAMETER FailOnSeverity The lowest PSScriptAnalyzer severity that fails a check. Findings below it are still recorded as diagnostics. It is part of the validation plan identity, so changing it invalidates an earlier receipt. .PARAMETER MarkdownLintPath A markdownlint executable. Without one the Markdown.Lint check is recorded as unavailable and the markdown entry stays unverified: the native structure check covers four rules and is not markdownlint. .PARAMETER MarkdownLintInterface The command-line interface the executable is expected to implement. Only an interface this implementation has verified is used; any other value makes the check unavailable rather than guessed at. .OUTPUTS PSCustomObject .EXAMPLE ./Invoke-ChangedFileValidation.ps1 -Path $PWD.Path | Format-Table RelativePath, Action, ValidationState Validates the default batch and reports what each entry now stands at. #> [CmdletBinding()] [OutputType([PSCustomObject])] param ( [Parameter(Mandatory = $true)] [ValidateNotNullOrEmpty()] [string]$Path, [Parameter()] [AllowEmptyString()] [string]$SessionId = 'default', [Parameter()] [switch]$Force, [Parameter()] [ValidateRange(1, 600)] [int]$TimeoutSecond = 60, [Parameter()] [ValidateRange(0, 600)] [int]$ExecutionWaitSecond = 30, [Parameter()] [ValidateRange(1, 102400)] [int]$MaxFileKilobyte = 2048, [Parameter()] [ValidateRange(1, 500)] [int]$MaxFileCount = 200, [Parameter()] [ValidateSet('Error', 'Warning')] [string]$FailOnSeverity = 'Error', [Parameter()] [AllowEmptyString()] [string]$MarkdownLintPath = '', [Parameter()] [ValidateSet('markdownlint-cli', 'markdownlint-cli2')] [string]$MarkdownLintInterface = 'markdownlint-cli' ) $ErrorActionPreference = 'Stop' Set-StrictMode -Version Latest . (Join-Path $PSScriptRoot 'ChangedFileValidationCommon.ps1') $context = Resolve-ChangedFileContext -Path $Path $session = Assert-ChangedFileSessionId -SessionId $SessionId $store = Read-ChangedFileStore -Context $context if ($null -eq $store) { return } $collected = @(Get-ChangedFileSessionEntry -Store $store -SessionId $session) if ($collected.Count -eq 0) { return } if ($collected.Count -gt $MaxFileCount) { throw "Session '$session' holds $($collected.Count) changed files, which is more than the requested bound of $MaxFileCount. Raise MaxFileCount deliberately or clear the batch." } $maxByte = [long]$MaxFileKilobyte * 1024 $result = [Collections.Generic.List[PSCustomObject]]::new() $writtenReceipt = @{} $reportArgument = @{ Context = $context SessionId = $session FailOnSeverity = $FailOnSeverity MarkdownLintPath = $MarkdownLintPath MarkdownLintInterface = $MarkdownLintInterface } # One validation run per session. Two runs over one batch would start two sets # of children and race on the same receipts. $executionLock = Enter-ChangedFileExecutionLock ` -Context $context ` -SessionId $session ` -TimeoutMillisecond ($ExecutionWaitSecond * 1000) try { foreach ($entry in $collected) { $relativePath = [string]$entry['path'] # The guard runs again immediately before the read: the batch may have # been collected minutes ago, and a link could have appeared since. $fullPath = Assert-ChangedFileRegularPath ` -LiteralPath (Join-Path $context.RepositoryRoot ($relativePath -replace '/', [IO.Path]::DirectorySeparatorChar)) ` -RepositoryRoot $context.RepositoryRoot ` -Field 'A collected changed path' $plan = Resolve-ChangedFileValidationPlan ` -Context $context ` -RelativePath $relativePath ` -FailOnSeverity $FailOnSeverity ` -MarkdownLintPath $MarkdownLintPath ` -MarkdownLintInterface $MarkdownLintInterface $validatorName = @($plan.Validator) $item = Get-Item -LiteralPath $fullPath -Force -ErrorAction SilentlyContinue if ($validatorName.Count -eq 0 -or $null -eq $item -or $item.PSIsContainer) { $result.Add(( Get-ChangedFileEntryReport @reportArgument -Entry $entry -Action 'Skipped' -Plan $plan )) continue } # The size bound comes before every content read: an oversized input is # refused without hashing it and without writing a receipt about it. if ($item.Length -gt $maxByte) { $oversized = @( foreach ($planned in $plan.Check) { New-ChangedFilePlannedCheck -Plan $planned -Outcome 'Unavailable' -Reason 'InputTooLarge' } ) $result.Add(( Get-ChangedFileEntryReport @reportArgument -Entry $entry -Action 'Skipped' -Plan $plan ` -TransientCheck $oversized -TransientState 'Incomplete' -TransientReason 'InputTooLarge' )) continue } $contentHash = Get-ChangedFileContentHash -LiteralPath $fullPath $receipt = if ($entry.ContainsKey('receipt')) { $entry['receipt'] } else { $null } $verdict = Test-ChangedFileReceipt -Receipt $receipt -Plan $plan -CurrentSha256 $contentHash if (-not $Force.IsPresent -and $verdict.IsCurrent) { $result.Add(( Get-ChangedFileEntryReport @reportArgument -Entry $entry -Action 'Reused' -Plan $plan )) continue } $stopwatch = [Diagnostics.Stopwatch]::StartNew() $check = [Collections.Generic.List[hashtable]]::new() $configPath = if ($null -ne $plan.MarkdownConfig) { $plan.MarkdownConfig.FullPath } else { '' } $snapshot = New-ChangedFileSnapshot -Context $context -FullPath $fullPath -ConfigPath $configPath try { if ($validatorName -contains 'PowerShell.Parse') { foreach ($produced in ( Invoke-ChangedFilePowerShellCheck ` -WorkingDirectory $snapshot.Directory ` -FileName $snapshot.FileName ` -FailOnSeverity $FailOnSeverity ` -TimeoutSecond $TimeoutSecond )) { $check.Add($produced) } } if ($validatorName -contains 'Markdown.NativeStructure') { $check.Add(( Invoke-ChangedFileMarkdownStructure ` -Plan ( $plan.Check | Where-Object -FilterScript { $_['Validator'] -eq 'Markdown.NativeStructure' } | Select-Object -First 1 ) ` -FullPath $snapshot.FullPath )) } if ($validatorName -contains 'Markdown.Lint') { $check.Add(( Invoke-ChangedFileMarkdownLint ` -Plan ( $plan.Check | Where-Object -FilterScript { $_['Validator'] -eq 'Markdown.Lint' } | Select-Object -First 1 ) ` -Context $context ` -Snapshot $snapshot ` -SourceFullPath $fullPath ` -MarkdownLintPath $MarkdownLintPath ` -MarkdownLintInterface $MarkdownLintInterface ` -MarkdownConfig $plan.MarkdownConfig ` -TimeoutSecond $TimeoutSecond )) } } finally { Remove-ChangedFileSnapshot -Directory $snapshot.Directory } $stopwatch.Stop() # Hash the file again. The receipt covers the snapshot the validators # read, so content that moved underneath the run leaves it stale. $afterHash = if (Test-Path -LiteralPath $fullPath -PathType Leaf) { Get-ChangedFileContentHash -LiteralPath $fullPath } else { $null } $newReceipt = @{ contentSha256 = $snapshot.Sha256 contentByte = $snapshot.Byte planIdentity = $plan.PlanIdentity completedUtc = [datetime]::UtcNow.ToString('o') durationMillisecond = [int]$stopwatch.Elapsed.TotalMilliseconds outcome = Get-ChangedFileWorstOutcome -Outcome @($check | ForEach-Object { [string]$_['outcome'] }) contentChangedDuringValidation = $afterHash -ne $snapshot.Sha256 checks = $check.ToArray() } $entry['receipt'] = $newReceipt $writtenReceipt[$relativePath] = $newReceipt $result.Add(( Get-ChangedFileEntryReport @reportArgument -Entry $entry -Action 'Validated' -Plan $plan )) } if ($writtenReceipt.Count -gt 0) { $lock = Enter-ChangedFileLock -Context $context try { # Re-read under the lock and merge onto whatever the batch looks like # now, so a receipt is never written onto a file another session or a # concurrent clear has already removed from the batch. $current = Read-ChangedFileStore -Context $context if ($null -ne $current) { $entries = @(Get-ChangedFileSessionEntry -Store $current -SessionId $session) foreach ($entry in $entries) { $relativePath = [string]$entry['path'] if ($writtenReceipt.ContainsKey($relativePath)) { $entry['receipt'] = $writtenReceipt[$relativePath] } } if ($entries.Count -gt 0) { $current['sessions'][$session]['entries'] = $entries $current['sessions'][$session]['updatedUtc'] = [datetime]::UtcNow.ToString('o') Write-ChangedFileStore -Context $context -Store $current } } } finally { Exit-ChangedFileLock -Handle $lock } } } finally { Exit-ChangedFileLock -Handle $executionLock } $result |