param( [string]$Path = ".", [string]$OutputPath = "scripts/missing-csharp-docs.txt", [switch]$IncludeMigrations, [switch]$IncludeGenerated, [switch]$Json ) $ErrorActionPreference = "Stop" $repoRoot = Resolve-Path (Join-Path $PSScriptRoot "..") $scanRoot = Resolve-Path (Join-Path $repoRoot $Path) $excludedDirectories = @( "\bin\", "\obj\", "\.git\", "\.vs\", "\node_modules\", "\dist\", "\logs\" ) if (-not $IncludeMigrations) { $excludedDirectories += "\Migrations\" } $memberRegexes = @( @{ Kind = "Type" Pattern = '^\s*(?:\[(?:[^\]]+)\]\s*)*(?:(?:public|private|protected|internal|static|abstract|sealed|partial|readonly|unsafe|file)\s+)*(?:class|interface|struct|enum|record(?:\s+(?:class|struct))?)\s+[A-Za-z_][A-Za-z0-9_]*' }, @{ Kind = "Delegate" Pattern = '^\s*(?:\[(?:[^\]]+)\]\s*)*(?:(?:public|private|protected|internal|static|virtual|abstract|sealed|override|new|unsafe|partial)\s+)*delegate\s+' }, @{ Kind = "Event" Pattern = '^\s*(?:\[(?:[^\]]+)\]\s*)*(?:(?:public|private|protected|internal|static|virtual|abstract|sealed|override|new|unsafe)\s+)*event\s+' }, @{ Kind = "Property" Pattern = '^\s*(?:\[(?:[^\]]+)\]\s*)*(?:(?:public|private|protected|internal|static|virtual|abstract|sealed|override|new|readonly|required|unsafe)\s+)+(?:[A-Za-z_][A-Za-z0-9_<>,\[\]\?\.\s\(\)]*|\([^\)]*\)\??)\s+[A-Za-z_][A-Za-z0-9_]*\s*\{\s*(?:get|set|init)\b' }, @{ Kind = "InterfaceProperty" Pattern = '^\s*(?:\[(?:[^\]]+)\]\s*)*(?:[A-Za-z_][A-Za-z0-9_<>,\[\]\?\.\s\(\)]*|\([^\)]*\)\??)\s+[A-Za-z_][A-Za-z0-9_]*\s*\{\s*(?:get|set|init)\b' }, @{ Kind = "Constructor" Pattern = '^\s*(?:\[(?:[^\]]+)\]\s*)*(?:(?:public|private|protected|internal|static|unsafe)\s+)+[A-Za-z_][A-Za-z0-9_]*\s*\(' }, @{ Kind = "Method" Pattern = '^\s*(?:\[(?:[^\]]+)\]\s*)*(?:(?:public|private|protected|internal|static|virtual|abstract|sealed|override|async|extern|new|unsafe|partial)\s+)+(?:[A-Za-z_][A-Za-z0-9_<>,\[\]\?\.\s\(\)]*|\([^\)]*\)\??)\s+(?:operator\s*[^\s\(]+|[A-Za-z_][A-Za-z0-9_]*)\s*(?:<[^>]+>)?\s*\(' }, @{ Kind = "InterfaceMethod" Pattern = '^\s*(?:\[(?:[^\]]+)\]\s*)*(?:[A-Za-z_][A-Za-z0-9_<>,\[\]\?\.\s\(\)]*|\([^\)]*\)\??)\s+[A-Za-z_][A-Za-z0-9_]*(?:<[^>]+>)?\s*\([^;{}]*\)\s*;' }, @{ Kind = "Field" Pattern = '^\s*(?:\[(?:[^\]]+)\]\s*)*(?:(?:public|private|protected|internal|static|readonly|const|volatile|new|unsafe)\s+)+(?:[A-Za-z_][A-Za-z0-9_<>,\[\]\?\.\s\(\)]*|\([^\)]*\)\??)\s+[A-Za-z_][A-Za-z0-9_]*(?:\s*=\s*[^;]+)?\s*;' } ) function Test-IsExcludedFile { param([System.IO.FileInfo]$File) $fullName = $File.FullName foreach ($directory in $excludedDirectories) { if ($fullName.Contains($directory)) { return $true } } if (-not $IncludeGenerated) { if ($File.Name -like "*.g.cs" -or $File.Name -like "*.g.i.cs" -or $File.Name -like "*.Designer.cs" -or $File.Name -like "*.AssemblyInfo.cs") { return $true } } return $false } function Get-RelativePath { param( [string]$BasePath, [string]$TargetPath ) $baseFullPath = [System.IO.Path]::GetFullPath($BasePath) if (-not $baseFullPath.EndsWith([System.IO.Path]::DirectorySeparatorChar)) { $baseFullPath += [System.IO.Path]::DirectorySeparatorChar } $targetFullPath = [System.IO.Path]::GetFullPath($TargetPath) $baseUri = New-Object System.Uri($baseFullPath) $targetUri = New-Object System.Uri($targetFullPath) $relativeUri = $baseUri.MakeRelativeUri($targetUri) return [System.Uri]::UnescapeDataString($relativeUri.ToString()).Replace("/", [System.IO.Path]::DirectorySeparatorChar) } function Remove-LineNoise { param([string]$Line) $lineWithoutStrings = [regex]::Replace($Line, '@?"(?:[^"\\]|\\.|"")*"', '""') return [regex]::Replace($lineWithoutStrings, '//.*$', '') } function Get-PreviousCodeLineIndex { param( [string[]]$Lines, [int]$StartIndex ) for ($i = $StartIndex; $i -ge 0; $i--) { $trimmed = $Lines[$i].Trim() if ([string]::IsNullOrWhiteSpace($trimmed)) { continue } if ($trimmed.StartsWith("[") -and $trimmed.EndsWith("]")) { continue } return $i } return -1 } function Test-HasXmlDoc { param( [string[]]$Lines, [int]$DeclarationIndex ) $previousIndex = Get-PreviousCodeLineIndex -Lines $Lines -StartIndex ($DeclarationIndex - 1) return $previousIndex -ge 0 -and $Lines[$previousIndex].TrimStart().StartsWith("///") } function Get-DeclarationText { param( [string[]]$Lines, [int]$StartIndex ) $parts = New-Object System.Collections.Generic.List[string] $maxIndex = [Math]::Min($Lines.Length - 1, $StartIndex + 8) for ($i = $StartIndex; $i -le $maxIndex; $i++) { $clean = Remove-LineNoise $Lines[$i] if ([string]::IsNullOrWhiteSpace($clean)) { continue } $parts.Add($clean.Trim()) $joined = ($parts -join " ") if ($joined -match '[\{;\}=]\s*$' -or $joined.Contains("=>")) { break } } return ($parts -join " ") } function Test-IsInsideInterface { param( [string[]]$Lines, [int]$Index ) $scopeStack = New-Object System.Collections.Generic.List[string] $pendingInterface = $false for ($i = 0; $i -lt $Index; $i++) { $line = Remove-LineNoise $Lines[$i] if ($line -match '\binterface\s+[A-Za-z_][A-Za-z0-9_]*') { $pendingInterface = $true } foreach ($char in $line.ToCharArray()) { if ($char -eq "{") { if ($pendingInterface) { $scopeStack.Add("interface") $pendingInterface = $false } else { $scopeStack.Add("block") } } elseif ($char -eq "}") { if ($scopeStack.Count -gt 0) { $scopeStack.RemoveAt($scopeStack.Count - 1) } } } } return $scopeStack.Contains("interface") } function Get-MemberName { param( [string]$Kind, [string]$Declaration ) switch ($Kind) { "Type" { if ($Declaration -match '\b(?:class|interface|struct|enum|record(?:\s+(?:class|struct))?)\s+(?[A-Za-z_][A-Za-z0-9_]*)') { return $Matches["name"] } } "Delegate" { if ($Declaration -match '\b(?[A-Za-z_][A-Za-z0-9_]*)\s*\(') { return $Matches["name"] } } "Event" { if ($Declaration -match '\bevent\s+[A-Za-z_][A-Za-z0-9_<>,\[\]\?\.\s]*\s+(?[A-Za-z_][A-Za-z0-9_]*)') { return $Matches["name"] } } "Constructor" { if ($Declaration -match '\b(?[A-Za-z_][A-Za-z0-9_]*)\s*\(') { return $Matches["name"] } } "Method" { $matches = [regex]::Matches($Declaration, '\s(?operator\s*[^\s\(]+|[A-Za-z_][A-Za-z0-9_]*)\s*(?:<[^>]+>)?\s*\(') if ($matches.Count -gt 0) { return $matches[$matches.Count - 1].Groups["name"].Value } } "InterfaceMethod" { $matches = [regex]::Matches($Declaration, '\s(?[A-Za-z_][A-Za-z0-9_]*)\s*(?:<[^>]+>)?\s*\(') if ($matches.Count -gt 0) { return $matches[$matches.Count - 1].Groups["name"].Value } } default { if ($Declaration -match '\b(?[A-Za-z_][A-Za-z0-9_]*)\s*(?:[=;\{])') { return $Matches["name"] } } } return "" } function Test-IsEnumMember { param( [string[]]$Lines, [int]$Index ) $line = Remove-LineNoise $Lines[$Index] if ($line -notmatch '^\s*[A-Za-z_][A-Za-z0-9_]*(?:\s*=\s*[^,]+)?\s*,?\s*$') { return $false } for ($i = $Index - 1; $i -ge 0; $i--) { $previous = Remove-LineNoise $Lines[$i] if ($previous -match '\benum\s+[A-Za-z_][A-Za-z0-9_]*') { return $true } if ($previous.Contains("{") -or $previous.Contains("}")) { return $false } } return $false } $files = Get-ChildItem -Path $scanRoot -Recurse -File -Filter "*.cs" | Where-Object { -not (Test-IsExcludedFile $_) } | Sort-Object FullName $results = New-Object System.Collections.Generic.List[object] foreach ($file in $files) { $lines = Get-Content $file.FullName -Encoding UTF8 $relativePath = Get-RelativePath -BasePath $repoRoot -TargetPath $file.FullName for ($i = 0; $i -lt $lines.Length; $i++) { $line = $lines[$i] $trimmed = $line.Trim() if ([string]::IsNullOrWhiteSpace($trimmed) -or $trimmed.StartsWith("///") -or $trimmed.StartsWith("//") -or $trimmed.StartsWith("#") -or $trimmed.StartsWith("[") -or $trimmed -in @("{", "}", "};")) { continue } $declaration = Get-DeclarationText -Lines $lines -StartIndex $i $matchedKind = $null foreach ($entry in $memberRegexes) { if ($declaration -cmatch $entry.Pattern) { if (($entry.Kind -eq "InterfaceMethod" -or $entry.Kind -eq "InterfaceProperty") -and -not (Test-IsInsideInterface -Lines $lines -Index $i)) { continue } $matchedKind = $entry.Kind break } } if ($null -eq $matchedKind -and (Test-IsEnumMember -Lines $lines -Index $i)) { $matchedKind = "EnumMember" } if ($null -eq $matchedKind) { continue } if (Test-HasXmlDoc -Lines $lines -DeclarationIndex $i) { continue } $results.Add([pscustomobject]@{ File = $relativePath Line = $i + 1 Kind = $matchedKind Name = Get-MemberName -Kind $matchedKind -Declaration $declaration Declaration = $declaration }) } } if ($Json) { $output = $results | ConvertTo-Json -Depth 4 } else { $output = $results | Format-Table File, Line, Kind, Name, Declaration -AutoSize | Out-String -Width 240 } if (-not [string]::IsNullOrWhiteSpace($OutputPath)) { $resolvedOutputPath = Join-Path $repoRoot $OutputPath $outputDirectory = Split-Path $resolvedOutputPath -Parent if (-not [string]::IsNullOrWhiteSpace($outputDirectory)) { New-Item -ItemType Directory -Path $outputDirectory -Force | Out-Null } Set-Content -Path $resolvedOutputPath -Value $output -Encoding UTF8 Write-Host "Missing XML documentation report written to $resolvedOutputPath" Write-Host "Total missing items: $($results.Count)" } else { $output Write-Host "Total missing items: $($results.Count)" }