| 1 | #requires -Version 7 |
| 2 | <# |
| 3 | .SYNOPSIS |
| 4 | Пересчитывает строки в partial-файлах MainWindowViewModel / IdeMcpCommandExecutor и обновляет таблицы в docs/architecture-migration.md. |
| 5 | |
| 6 | .DESCRIPTION |
| 7 | Колонка «Содержание»: по умолчанию берётся из XML-док-комментария `<summary>` непосредственно над |
| 8 | `partial class MainWindowViewModel` / `partial class IdeMcpCommandExecutor` в соответствующем .cs. |
| 9 | Если там нет summary — подставляется строка из |
| 10 | tools/architecture-migration-slice/main-window-slice-descriptions.json (необязательный fallback; может быть ``{}``). |
| 11 | Шаблон абзаца сводки — tools/architecture-migration-slice/main-window-slice-summary.template.md |
| 12 | (плейсхолдеры {0}…{3}). Каталог не tools/data: там срабатывает игнор Data/ в .gitignore. |
| 13 | |
| 14 | .PARAMETER RepoRoot |
| 15 | Корень репозитория cascade-ide (родитель каталога tools). |
| 16 | |
| 17 | .PARAMETER DryRun |
| 18 | Печатает фрагменты в консоль, файл не перезаписывает. |
| 19 | |
| 20 | .PARAMETER Verify |
| 21 | Сверяет docs/architecture-migration.md с пересчётом; при расхождении exit 1 (для pre-commit). |
| 22 | |
| 23 | .EXAMPLE |
| 24 | pwsh ./tools/Update-ArchitectureMigrationMainWindowSlice.ps1 |
| 25 | .EXAMPLE |
| 26 | pwsh ./tools/Update-ArchitectureMigrationMainWindowSlice.ps1 -Verify |
| 27 | #> |
| 28 | [CmdletBinding()] |
| 29 | param( |
| 30 | [string] $RepoRoot = (Resolve-Path (Join-Path $PSScriptRoot '..')).Path, |
| 31 | [switch] $DryRun, |
| 32 | [switch] $Verify |
| 33 | ) |
| 34 | |
| 35 | Set-StrictMode -Version Latest |
| 36 | $ErrorActionPreference = 'Stop' |
| 37 | |
| 38 | function Get-LineCount([string] $path) { |
| 39 | if (-not (Test-Path -LiteralPath $path)) { return 0 } |
| 40 | return (Get-Content -LiteralPath $path | Measure-Object -Line).Lines |
| 41 | } |
| 42 | |
| 43 | function Read-OptionalDescriptionsJson([string] $jsonPath) { |
| 44 | if (-not (Test-Path -LiteralPath $jsonPath)) { |
| 45 | return @{} |
| 46 | } |
| 47 | $raw = Get-Content -LiteralPath $jsonPath -Raw -Encoding UTF8 |
| 48 | if ([string]::IsNullOrWhiteSpace($raw)) { |
| 49 | return @{} |
| 50 | } |
| 51 | $o = $raw | ConvertFrom-Json |
| 52 | $ht = @{} |
| 53 | foreach ($p in $o.PSObject.Properties) { |
| 54 | $ht[$p.Name] = [string]$p.Value |
| 55 | } |
| 56 | return $ht |
| 57 | } |
| 58 | |
| 59 | function Get-CrefDisplayFragment([string] $cref) { |
| 60 | $c = $cref.Trim() |
| 61 | if ($c.Length -ge 2 -and [char]::IsLetter($c[0]) -and $c[1] -eq ':') { |
| 62 | $c = $c.Substring(2) |
| 63 | } |
| 64 | $paren = $c.IndexOf('(') |
| 65 | if ($paren -ge 0) { |
| 66 | $c = $c.Substring(0, $paren) |
| 67 | } |
| 68 | $i = $c.LastIndexOf('.') |
| 69 | if ($i -ge 0) { |
| 70 | return $c.Substring($i + 1) |
| 71 | } |
| 72 | return $c |
| 73 | } |
| 74 | |
| 75 | function Normalize-XmlDocSummaryForMarkdown([string] $text) { |
| 76 | if ([string]::IsNullOrWhiteSpace($text)) { |
| 77 | return '' |
| 78 | } |
| 79 | $t = $text |
| 80 | $t = [regex]::Replace($t, '<c>([^<]*)</c>', { param($m) [char]0x0060 + $m.Groups[1].Value + [char]0x0060 }) |
| 81 | $t = [regex]::Replace($t, '<see\s+cref="([^"]+)"\s*/>', { param($m) [char]0x0060 + (Get-CrefDisplayFragment $m.Groups[1].Value) + [char]0x0060 }) |
| 82 | $t = [regex]::Replace($t, '<see\s+langword="([^"]+)"\s*/>', { param($m) [char]0x0060 + $m.Groups[1].Value + [char]0x0060 }) |
| 83 | $t = [regex]::Replace($t, '<[^>]+>', '') |
| 84 | $t = $t -replace '<', '<' -replace '>', '>' -replace '&', '&' -replace '"', '"' |
| 85 | $t = [regex]::Replace($t, '\s+', ' ').Trim() |
| 86 | $t = $t -replace '\|', '¦' |
| 87 | return $t |
| 88 | } |
| 89 | |
| 90 | function Get-FileLevelXmlSummary([string] $path, [string] $classBaseName) { |
| 91 | $lines = @(Get-Content -LiteralPath $path -Encoding UTF8) |
| 92 | $escaped = [regex]::Escape($classBaseName) |
| 93 | $pattern = "^\s*(?:public|internal)\s+(?:sealed\s+)?partial\s+class\s+$escaped\b" |
| 94 | $classLineIndex = -1 |
| 95 | for ($i = 0; $i -lt $lines.Count; $i++) { |
| 96 | if ($lines[$i] -match $pattern) { |
| 97 | $classLineIndex = $i |
| 98 | break |
| 99 | } |
| 100 | } |
| 101 | if ($classLineIndex -lt 0) { |
| 102 | return '' |
| 103 | } |
| 104 | $i = $classLineIndex - 1 |
| 105 | while ($i -ge 0 -and [string]::IsNullOrWhiteSpace($lines[$i])) { |
| 106 | $i-- |
| 107 | } |
| 108 | # Атрибуты между </summary> и объявлением класса — не XML; пропускаем однострочные [ ... ] над partial. |
| 109 | while ($i -ge 0 -and $lines[$i] -match '^\s*\[') { |
| 110 | $i-- |
| 111 | while ($i -ge 0 -and [string]::IsNullOrWhiteSpace($lines[$i])) { |
| 112 | $i-- |
| 113 | } |
| 114 | } |
| 115 | $docLines = [System.Collections.Generic.List[string]]::new() |
| 116 | while ($i -ge 0 -and $lines[$i] -match '^\s*///') { |
| 117 | $docLines.Insert(0, $lines[$i]) |
| 118 | $i-- |
| 119 | } |
| 120 | if ($docLines.Count -eq 0) { |
| 121 | return '' |
| 122 | } |
| 123 | $stripped = foreach ($dl in $docLines) { |
| 124 | if ($dl -match '^\s*///\s?(.*)$') { |
| 125 | $Matches[1] |
| 126 | } |
| 127 | else { |
| 128 | '' |
| 129 | } |
| 130 | } |
| 131 | $block = $stripped -join "`n" |
| 132 | if ($block -notmatch '<summary>([\s\S]*?)</summary>') { |
| 133 | return '' |
| 134 | } |
| 135 | $inner = $Matches[1].Trim() |
| 136 | return (Normalize-XmlDocSummaryForMarkdown $inner) |
| 137 | } |
| 138 | |
| 139 | function Resolve-Description([string] $path, [string] $tableKey, [string] $classBaseName, [hashtable] $fallback) { |
| 140 | $fromXml = Get-FileLevelXmlSummary $path $classBaseName |
| 141 | if (-not [string]::IsNullOrWhiteSpace($fromXml)) { |
| 142 | return $fromXml |
| 143 | } |
| 144 | if ($fallback.ContainsKey($tableKey) -and -not [string]::IsNullOrWhiteSpace($fallback[$tableKey])) { |
| 145 | return [string]$fallback[$tableKey] |
| 146 | } |
| 147 | throw "Нет XML <summary> над partial class в ``$tableKey`` и нет строки в main-window-slice-descriptions.json для этого ключа." |
| 148 | } |
| 149 | |
| 150 | $contentDir = Join-Path $PSScriptRoot 'architecture-migration-slice' |
| 151 | $descriptionsPath = Join-Path $contentDir 'main-window-slice-descriptions.json' |
| 152 | $summaryTemplatePath = Join-Path $contentDir 'main-window-slice-summary.template.md' |
| 153 | |
| 154 | $Fallback = Read-OptionalDescriptionsJson $descriptionsPath |
| 155 | |
| 156 | if (-not (Test-Path -LiteralPath $summaryTemplatePath)) { |
| 157 | throw "Нет шаблона сводки: $summaryTemplatePath" |
| 158 | } |
| 159 | $summaryTemplate = (Get-Content -LiteralPath $summaryTemplatePath -Raw -Encoding UTF8).Trim() |
| 160 | |
| 161 | $viewModels = Join-Path $RepoRoot 'ViewModels' |
| 162 | $mwFiles = Get-ChildItem -LiteralPath $viewModels -Filter 'MainWindowViewModel*.cs' -File | Sort-Object Name |
| 163 | $execFiles = [System.Collections.Generic.List[object]]::new() |
| 164 | foreach ($x in Get-ChildItem -LiteralPath $viewModels -Filter 'IdeMcpCommandExecutor*.cs' -File | Sort-Object Name) { |
| 165 | $execFiles.Add($x) |
| 166 | } |
| 167 | $genPath = Join-Path $viewModels 'Generated/IdeMcpCommandExecutor.Generated.g.cs' |
| 168 | if (Test-Path -LiteralPath $genPath) { |
| 169 | $execFiles.Add((Get-Item -LiteralPath $genPath)) |
| 170 | } |
| 171 | |
| 172 | function Build-Table([System.Collections.Generic.List[object]] $rows) { |
| 173 | $sb = [System.Text.StringBuilder]::new() |
| 174 | [void]$sb.AppendLine('| Файл | Строк (≈) | Содержание |') |
| 175 | [void]$sb.AppendLine('|------|------------|------------|') |
| 176 | foreach ($r in $rows) { |
| 177 | [void]$sb.AppendLine("| ``$($r.Name)`` | $($r.Lines) | $($r.Desc) |") |
| 178 | } |
| 179 | return $sb.ToString().TrimEnd() |
| 180 | } |
| 181 | |
| 182 | $mwRows = [System.Collections.Generic.List[object]]::new() |
| 183 | $sumMw = 0 |
| 184 | foreach ($f in $mwFiles) { |
| 185 | $name = $f.Name |
| 186 | $lines = Get-LineCount $f.FullName |
| 187 | $sumMw += $lines |
| 188 | $desc = Resolve-Description $f.FullName $name 'MainWindowViewModel' $Fallback |
| 189 | $mwRows.Add([pscustomobject]@{ Name = $name; Lines = $lines; Desc = $desc }) |
| 190 | } |
| 191 | |
| 192 | $execRows = [System.Collections.Generic.List[object]]::new() |
| 193 | $sumExec = 0 |
| 194 | foreach ($f in $execFiles) { |
| 195 | if ($null -eq $f) { continue } |
| 196 | $rel = if ($f.DirectoryName -match 'Generated') { 'Generated/' + $f.Name } else { $f.Name } |
| 197 | $lines = Get-LineCount $f.FullName |
| 198 | $sumExec += $lines |
| 199 | $desc = Resolve-Description $f.FullName $rel 'IdeMcpCommandExecutor' $Fallback |
| 200 | $execRows.Add([pscustomobject]@{ Name = $rel; Lines = $lines; Desc = $desc }) |
| 201 | } |
| 202 | |
| 203 | $total = $sumMw + $sumExec |
| 204 | $roundTotal = [math]::Round($total / 1000, 1) |
| 205 | $roundMw = [math]::Round($sumMw / 1000, 1) |
| 206 | $roundExec = [math]::Round($sumExec / 1000, 1) |
| 207 | $ym = Get-Date -Format 'yyyy-MM' |
| 208 | |
| 209 | $summaryParagraph = [string]::Format( |
| 210 | [System.Globalization.CultureInfo]::InvariantCulture, |
| 211 | $summaryTemplate, |
| 212 | $roundTotal, |
| 213 | $roundMw, |
| 214 | $roundExec, |
| 215 | $ym) |
| 216 | |
| 217 | $mwTable = Build-Table $mwRows |
| 218 | $execTable = Build-Table $execRows |
| 219 | |
| 220 | if ($DryRun -and $Verify) { |
| 221 | throw "Нельзя одновременно -DryRun и -Verify." |
| 222 | } |
| 223 | |
| 224 | if ($DryRun) { |
| 225 | Write-Host '--- SUMMARY ---' |
| 226 | Write-Host $summaryParagraph |
| 227 | Write-Host '--- MW TABLE ---' |
| 228 | Write-Host $mwTable |
| 229 | Write-Host '--- EXEC TABLE ---' |
| 230 | Write-Host $execTable |
| 231 | Write-Host "Totals: MW=$sumMw Exec=$sumExec Total=$total" |
| 232 | return |
| 233 | } |
| 234 | |
| 235 | $docPath = Join-Path $RepoRoot 'docs/architecture-migration.md' |
| 236 | $text = Get-Content -LiteralPath $docPath -Raw -Encoding UTF8 |
| 237 | |
| 238 | $beginSum = '<!-- AUTO:MAIN-WINDOW-SLICE:SUMMARY:BEGIN -->' |
| 239 | $endSum = '<!-- AUTO:MAIN-WINDOW-SLICE:SUMMARY:END -->' |
| 240 | $beginMw = '<!-- AUTO:MAIN-WINDOW-SLICE:MWVM-TABLE:BEGIN -->' |
| 241 | $endMw = '<!-- AUTO:MAIN-WINDOW-SLICE:MWVM-TABLE:END -->' |
| 242 | $beginEx = '<!-- AUTO:MAIN-WINDOW-SLICE:EXEC-TABLE:BEGIN -->' |
| 243 | $endEx = '<!-- AUTO:MAIN-WINDOW-SLICE:EXEC-TABLE:END -->' |
| 244 | |
| 245 | function Replace-Between([string] $src, [string] $a, [string] $b, [string] $inner) { |
| 246 | $i1 = $src.IndexOf($a, [StringComparison]::Ordinal) |
| 247 | $i2 = $src.IndexOf($b, [StringComparison]::Ordinal) |
| 248 | if ($i1 -lt 0) { throw "Маркер начала не найден: $a" } |
| 249 | if ($i2 -lt 0) { throw "Маркер конца не найден: $b" } |
| 250 | if ($i2 -le $i1) { throw "Маркер конца раньше начала: $a ... $b" } |
| 251 | $endA = $i1 + $a.Length |
| 252 | return $src.Substring(0, $endA) + "`n`n" + $inner.TrimEnd() + "`n`n" + $src.Substring($i2) |
| 253 | } |
| 254 | |
| 255 | $text = Replace-Between $text $beginSum $endSum $summaryParagraph |
| 256 | $text = Replace-Between $text $beginMw $endMw $mwTable |
| 257 | $text = Replace-Between $text $beginEx $endEx $execTable |
| 258 | |
| 259 | $expected = $text.TrimEnd() + "`n" |
| 260 | function Normalize-Text([string] $s) { |
| 261 | return ($s -replace "`r`n", "`n" -replace "`r", "`n") |
| 262 | } |
| 263 | |
| 264 | if ($Verify) { |
| 265 | if (-not (Test-Path -LiteralPath $docPath)) { |
| 266 | throw "Нет файла: $docPath" |
| 267 | } |
| 268 | $utf8NoBomRead = [System.Text.UTF8Encoding]::new($false) |
| 269 | $onDisk = [System.IO.File]::ReadAllText($docPath, $utf8NoBomRead) |
| 270 | if ((Normalize-Text $expected) -ne (Normalize-Text $onDisk)) { |
| 271 | Write-Error "architecture-migration.md не совпадает с пересчётом среза. Запусти из корня cascade-ide: pwsh ./tools/Update-ArchitectureMigrationMainWindowSlice.ps1" |
| 272 | exit 1 |
| 273 | } |
| 274 | Write-Host "OK verify: $docPath (MW lines=$sumMw, Exec lines=$sumExec, total=$total)" |
| 275 | exit 0 |
| 276 | } |
| 277 | |
| 278 | $utf8NoBom = [System.Text.UTF8Encoding]::new($false) |
| 279 | [System.IO.File]::WriteAllText($docPath, $expected, $utf8NoBom) |
| 280 | Write-Host "OK: $docPath (MW lines=$sumMw, Exec lines=$sumExec, total=$total)" |
| 281 | |