[CmdletBinding()] param( [Parameter(Mandatory = $true)] [string]$Path ) Set-StrictMode -Version Latest $ErrorActionPreference = "Stop" function Resolve-AuditTargets { param( [string]$InputPath ) if (-not (Test-Path -LiteralPath $InputPath)) { throw "Path not found: $InputPath" } $item = Get-Item -LiteralPath $InputPath if ($item.PSIsContainer) { return @(Get-ChildItem -LiteralPath $item.FullName -Recurse -File -Filter "*.md" | Sort-Object FullName) } if ($item.Extension -ne ".md") { throw "Only Markdown files are supported: $($item.FullName)" } return @($item) } function Get-FirstMeaningfulLine { param( [string[]]$Lines ) foreach ($line in $Lines) { if (-not [string]::IsNullOrWhiteSpace($line)) { return $line.Trim() } } return "" } function Get-BlockPreview { param( [string]$Code ) $preview = "" foreach ($line in ($Code -split "`r?`n")) { if (-not [string]::IsNullOrWhiteSpace($line)) { $preview = $line.Trim() break } } if ([string]::IsNullOrWhiteSpace($preview)) { return "" } if ($preview.Length -gt 100) { return $preview.Substring(0, 100) + "..." } return $preview } function Get-SkipReason { param( [string]$Language, [string]$Code ) if ($Language -eq "text") { return "text block" } $trimmed = $Code.Trim() if ([string]::IsNullOrWhiteSpace($trimmed)) { return "empty block" } if ($trimmed -match '(?m)^\s*statement;\s*$') { return "grammar placeholder" } if ($trimmed -match '…+' -or $trimmed -match '(?m)^\s*(//\s*)?\.{3,}\s*$') { return "ellipsis placeholder" } if ($trimmed -match '<[^>\r\n]+>') { return "angle-bracket placeholder" } $meaningfulLines = @( ($Code -split "`r?`n" | Where-Object { -not [string]::IsNullOrWhiteSpace($_) } | ForEach-Object { $_.Trim() }) ) if ($meaningfulLines.Count -gt 1 -and $meaningfulLines[0] -eq "begin" -and (($meaningfulLines | Select-Object -Skip 1) -match '^(?i:(function|unit|type|class|const|var|namespace|uses))')) { return "mixed non-standalone snippet" } return $null } function Get-CompileKind { param( [string]$Code ) $firstLine = Get-FirstMeaningfulLine -Lines ($Code -split "`r?`n") if ($firstLine -match '^(?i:(function|unit|type|class|const|var|namespace|uses|\{\$))') { return "tsf" } return "tsl" } function Invoke-TslCompile { param( [string]$Code, [ValidateSet("tsl", "tsf")] [string]$Kind ) $tempRoot = Join-Path ([System.IO.Path]::GetTempPath()) ("tsl-doc-audit-" + [guid]::NewGuid().ToString("N")) New-Item -ItemType Directory -Path $tempRoot | Out-Null $process = $null try { $sourcePath = Join-Path $tempRoot ("snippet." + $Kind) [System.IO.File]::WriteAllText($sourcePath, $Code, [System.Text.UTF8Encoding]::new($false)) $psi = New-Object System.Diagnostics.ProcessStartInfo $psi.FileName = 'tsl' $psi.Arguments = "-COMPILE `"$sourcePath`"" $psi.RedirectStandardOutput = $true $psi.RedirectStandardError = $true $psi.RedirectStandardInput = $true $psi.UseShellExecute = $false $psi.CreateNoWindow = $true $process = New-Object System.Diagnostics.Process $process.StartInfo = $psi $process.Start() | Out-Null $process.StandardInput.WriteLine('exit') $process.StandardInput.Flush() $process.WaitForExit(30000) | Out-Null $stdout = $process.StandardOutput.ReadToEnd() $stderr = $process.StandardError.ReadToEnd() $outputText = ($stdout + "`n" + $stderr).Trim() $lowerText = $outputText.ToLowerInvariant() $success = $lowerText -match 'compile success' $failure = $lowerText -match 'compile error' if ($success) { return [pscustomobject]@{ Success = $true; Output = $outputText } } if ($failure) { return [pscustomobject]@{ Success = $false; Output = $outputText } } return [pscustomobject]@{ Success = $false; Output = (if ($outputText) { $outputText } else { "" }) } } finally { if ($process -and -not $process.HasExited) { $process.Kill() $process.WaitForExit() } if (Test-Path -LiteralPath $tempRoot) { Remove-Item -LiteralPath $tempRoot -Recurse -Force } } } function Wrap-AsFunctionBody { param( [string]$Code ) $body = $Code -split "`r?`n" | ForEach-Object { " $_" } return @( "function __doc_check__();" "begin" $body "end;" "" ) -join "`n" } function Parse-MarkdownBlocks { param( [string]$Content ) $blocks = New-Object System.Collections.Generic.List[object] $lines = $Content -split "`r?`n" $inFence = $false $fenceLang = "" $fenceStartLine = 0 $buffer = New-Object System.Collections.Generic.List[string] for ($i = 0; $i -lt $lines.Length; $i++) { $line = $lines[$i] if (-not $inFence) { if ($line -match '^\s*```([A-Za-z0-9_-]*)\s*$') { $inFence = $true $fenceLang = $Matches[1].ToLowerInvariant() $fenceStartLine = $i + 1 $buffer.Clear() } continue } if ($line -match '^\s*```\s*$') { $blocks.Add([pscustomobject]@{ Language = $fenceLang StartLine = $fenceStartLine Code = ($buffer -join "`n") }) $inFence = $false $fenceLang = "" $fenceStartLine = 0 $buffer.Clear() continue } $buffer.Add($line) } return $blocks } $targets = Resolve-AuditTargets -InputPath $Path $grandPass = 0 $grandSkip = 0 $grandFail = 0 foreach ($target in $targets) { $content = Get-Content -LiteralPath $target.FullName -Raw $blocks = Parse-MarkdownBlocks -Content $content $results = New-Object System.Collections.Generic.List[object] foreach ($block in $blocks) { if ($block.Language -notin @("tsl", "text")) { continue } $skipReason = Get-SkipReason -Language $block.Language -Code $block.Code if ($null -ne $skipReason) { $results.Add([pscustomobject]@{ Status = "skip" StartLine = $block.StartLine Preview = Get-BlockPreview -Code $block.Code Detail = $skipReason }) continue } $kind = Get-CompileKind -Code $block.Code $compile = Invoke-TslCompile -Code $block.Code -Kind $kind if ($compile.Success) { $results.Add([pscustomobject]@{ Status = "pass" StartLine = $block.StartLine Preview = Get-BlockPreview -Code $block.Code Detail = $kind }) continue } if ($kind -eq "tsl") { $wrappedCode = Wrap-AsFunctionBody -Code $block.Code $wrappedCompile = Invoke-TslCompile -Code $wrappedCode -Kind "tsf" if ($wrappedCompile.Success) { $results.Add([pscustomobject]@{ Status = "pass" StartLine = $block.StartLine Preview = Get-BlockPreview -Code $block.Code Detail = "wrapped tsf" }) continue } $compile = $wrappedCompile } $results.Add([pscustomobject]@{ Status = "fail" StartLine = $block.StartLine Preview = Get-BlockPreview -Code $block.Code Detail = ($compile.Output.Trim()) }) } $passCount = @($results | Where-Object Status -eq "pass").Count $skipCount = @($results | Where-Object Status -eq "skip").Count $failCount = @($results | Where-Object Status -eq "fail").Count $grandPass += $passCount $grandSkip += $skipCount $grandFail += $failCount Write-Output "$($target.FullName): pass=$passCount skip=$skipCount fail=$failCount" foreach ($failure in ($results | Where-Object Status -eq "fail")) { Write-Output " FAIL line $($failure.StartLine): $($failure.Preview)" foreach ($detailLine in ($failure.Detail -split "`r?`n")) { if (-not [string]::IsNullOrWhiteSpace($detailLine)) { Write-Output " $detailLine" } } } } Write-Output "TOTAL: pass=$grandPass skip=$grandSkip fail=$grandFail" if ($grandFail -gt 0) { exit 1 } exit 0