Forge
powershelldeeb25a2
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()]
29param(
30 [string] $RepoRoot = (Resolve-Path (Join-Path $PSScriptRoot '..')).Path,
31 [switch] $DryRun,
32 [switch] $Verify
33)
34
35Set-StrictMode -Version Latest
36$ErrorActionPreference = 'Stop'
37
38function 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
43function 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
59function 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
75function 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 '&lt;', '<' -replace '&gt;', '>' -replace '&amp;', '&' -replace '&quot;', '"'
85 $t = [regex]::Replace($t, '\s+', ' ').Trim()
86 $t = $t -replace '\|', '¦'
87 return $t
88}
89
90function 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
139function 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
156if (-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()
164foreach ($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'
168if (Test-Path -LiteralPath $genPath) {
169 $execFiles.Add((Get-Item -LiteralPath $genPath))
170}
171
172function 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
184foreach ($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
194foreach ($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
220if ($DryRun -and $Verify) {
221 throw "Нельзя одновременно -DryRun и -Verify."
222}
223
224if ($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
245function 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"
260function Normalize-Text([string] $s) {
261 return ($s -replace "`r`n", "`n" -replace "`r", "`n")
262}
263
264if ($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)
280Write-Host "OK: $docPath (MW lines=$sumMw, Exec lines=$sumExec, total=$total)"
281
View only · write via MCP/CIDE