AvaloniaStack/scripts/find-missing-csharp-docs.ps1
luoqian fc6f9f6bc3 docs: 补全全部缺失的 XML 文档注释(中文)
- 为全部 5 个项目(Avalonia-API、Avalonia-Common、Avalonia-EFCore、
  Avalonia-PC、Avalonia-Services)中缺失注释的类、方法、属性、字段、
  接口成员等补全中文 XML 文档注释
- 共修改约 37 个文件,补全约 220+ 处注释
- 修复 ServiceEndpointCollection.cs 中 MapDelete<TService> 语法错误
- 修复 PcAuthService.cs 中 const prefix 位置错乱导致编译失败的问题
- 扫描结果:缺失项 0
- 构建结果:4/4 项目编译通过
2026-05-18 11:35:13 +08:00

359 lines
11 KiB
PowerShell

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+(?<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" {
$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
}
}
"InterfaceMethod" {
$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
}
}
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)"
}