2026-05-15 18:03:15 +08:00
|
|
|
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"
|
2026-05-18 11:35:13 +08:00
|
|
|
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'
|
2026-05-15 18:03:15 +08:00
|
|
|
},
|
|
|
|
|
@{
|
|
|
|
|
Kind = "InterfaceProperty"
|
2026-05-18 11:35:13 +08:00
|
|
|
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'
|
2026-05-15 18:03:15 +08:00
|
|
|
},
|
|
|
|
|
@{
|
|
|
|
|
Kind = "Constructor"
|
|
|
|
|
Pattern = '^\s*(?:\[(?:[^\]]+)\]\s*)*(?:(?:public|private|protected|internal|static|unsafe)\s+)+[A-Za-z_][A-Za-z0-9_]*\s*\('
|
|
|
|
|
},
|
|
|
|
|
@{
|
|
|
|
|
Kind = "Method"
|
2026-05-18 11:35:13 +08:00
|
|
|
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*\('
|
2026-05-15 18:03:15 +08:00
|
|
|
},
|
|
|
|
|
@{
|
|
|
|
|
Kind = "InterfaceMethod"
|
2026-05-18 11:35:13 +08:00
|
|
|
Pattern = '^\s*(?:\[(?:[^\]]+)\]\s*)*(?:[A-Za-z_][A-Za-z0-9_<>,\[\]\?\.\s\(\)]*|\([^\)]*\)\??)\s+[A-Za-z_][A-Za-z0-9_]*(?:<[^>]+>)?\s*\([^;{}]*\)\s*;'
|
2026-05-15 18:03:15 +08:00
|
|
|
},
|
|
|
|
|
@{
|
|
|
|
|
Kind = "Field"
|
2026-05-18 11:35:13 +08:00
|
|
|
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*;'
|
2026-05-15 18:03:15 +08:00
|
|
|
}
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
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+(?<name>[A-Za-z_][A-Za-z0-9_]*)') {
|
|
|
|
|
return $Matches["name"]
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
"Delegate" {
|
|
|
|
|
if ($Declaration -match '\b(?<name>[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+(?<name>[A-Za-z_][A-Za-z0-9_]*)') {
|
|
|
|
|
return $Matches["name"]
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
"Constructor" {
|
|
|
|
|
if ($Declaration -match '\b(?<name>[A-Za-z_][A-Za-z0-9_]*)\s*\(') {
|
|
|
|
|
return $Matches["name"]
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
"Method" {
|
2026-05-18 11:35:13 +08:00
|
|
|
$matches = [regex]::Matches($Declaration, '\s(?<name>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
|
2026-05-15 18:03:15 +08:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
"InterfaceMethod" {
|
2026-05-18 11:35:13 +08:00
|
|
|
$matches = [regex]::Matches($Declaration, '\s(?<name>[A-Za-z_][A-Za-z0-9_]*)\s*(?:<[^>]+>)?\s*\(')
|
|
|
|
|
if ($matches.Count -gt 0) {
|
|
|
|
|
return $matches[$matches.Count - 1].Groups["name"].Value
|
2026-05-15 18:03:15 +08:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
default {
|
|
|
|
|
if ($Declaration -match '\b(?<name>[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)"
|
|
|
|
|
}
|