Skip to content

release: v2.22 - #12

Merged
bivlked merged 11 commits into
mainfrom
feature/v2.22
Jul 23, 2026
Merged

release: v2.22#12
bivlked merged 11 commits into
mainfrom
feature/v2.22

Conversation

@bivlked

@bivlked bivlked commented Jul 23, 2026

Copy link
Copy Markdown
Owner

Что в релизе

Релиз про то, как отличить «работа закончена» от «я не смог этого увидеть», и про то,
чтобы прогон переживал вещи, не имеющие отношения к обслуживанию.

Исправлено

  • Disk Cleanup, переставший что-либо делать, считался работающим и стоил полного
    таймаута.
    Замер на живой машине: cleanmgr /sagerun отработал за ~10 секунд, закрыл
    окно и остался резидентным - без CPU, без I/O по всем трём счётчикам. Ожидание было
    написано на выход процесса, поэтому прогон стоял оставшиеся ~890 секунд и после этого
    объявлял цифры неполными. Теперь ожидание завершается и по полной неподвижности (12
    проверок подряд без CPU и I/O, после того как активность была зафиксирована хотя бы
    раз). Это названо тем, чем является - наблюдением, а не доказательством: статус
    idle-resident. Невозможность измерить активность сбрасывает счётчик, а не
    продвигает его, поэтому на машине со сломанным WMI поведение остаётся прежним.
  • Неработоспособный -LogPath убивал прогон до начала обслуживания. Заголовок лога
    писался двумя голыми Out-File до блока, который гарантирует result JSON и сводку.
    Шесть из семи плохих путей заставляют Out-File бросить terminating-ошибку даже при
    дефолтном ErrorActionPreference (измерено), и исключение уходило из функции целиком.
    Ненаписуемый лог теперь деградация, а не провал - как везде остальное.
  • install.ps1 принимал PowerShell 7, версию которого не смог прочитать. Проверка
    if ($pwshVersion -and $pwshVersion -lt '7.1') при нечитаемой версии просто не
    выполнялась, и установщик шёл дальше вешать elevated-ярлык на бинарь, пригодность
    которого никто не устанавливал. Та же fail-open форма, что у проверки SHA256, прятавшейся
    внутри if ($hashAsset) до v2.17.
  • Рамка баннера держалась на том, что версия занимает ровно четыре символа.

Изменено

  • Вывод следует за окном консоли. Ярлык открывает conhost на дефолтных 120 колонках, а
    таблице winget upgrade на локализованной системе нужно ~140 - она переносилась поверх
    нашего вывода. Окно расширяется best-effort при старте (не шире физически возможного, без
    падения если хост откажет), рамки считаются от реальной ширины вместо литерала 70 в
    четырёх местах. Границы: не уже исторических 70, не шире 90.
  • Все выходы из прогона идут одним путём. Успешное самообновление вызывало exit
    само, минуя блок с result JSON, сводкой и освобождением лог-хендла. Именно поэтому в
    v2.21 пришлось дважды чинить одни и те же несколько строк. Коды возврата не изменились.
  • Storage Sense: лог различает «выключен» и «включён, но чистить было нечего».
    Осознанно только наблюдаемость, без изменения решения: пропуск Disk Cleanup на этом
    основании молча прекратил бы чистку Update Cleanup, дампов памяти, Language Pack, старых
    ChkDsk и WER - ничего из этого Storage Sense не делает.

Добавлено

  • DiskCleanupStatus в result JSON - 12 значений вместо булева поля, покрывавшего две
    разные правды. Прежний failed разделён на not-armed, start-failed и exit-nonzero:
    только последнее означает, что машина может быть очищена частично.
  • SUPPORT.md, шаблон release notes в docs/release-process.md, раздел «что принимается»
    в CONTRIBUTING.md.

Тесты

573 -> 702. Четыре мутационных прогона по новой логике (79 мутаций), все пойманы. Восемь
сначала выжили, и каждая вскрыла реальный пробел, а не требовала подгонки теста. Геометрия
рамок проверяется на 70, 74, 80 и 90 колонках.

Как проверено

  • Релиз-гейт pwsh tools/Invoke-ReleaseCheck.ps1: 10/10.
  • Pester 702/702, ни одного пропущенного.
  • Ревью: 6 раундов Codex (каждый находил дефекты в исправлениях предыдущего) + 4 агента с
    мутационным прогоном. Агенты нашли класс, которого Codex не видел: главная фича релиза не
    имела поведенческого покрытия вообще.
  • Стенд: VM 190 (RU) и VM 191 (EN), режимы Full и ReportNoCleanup.

Отклонено проверкой (не переоткрывать)

Замечание Чем опровергнуто
Приоритет -in/-and в Invoke-StandTest.ps1 AST показал группировку ($Mode -in @(...)) -and (-not ...), таблица истинности верна
Публичный README отстаёт (6 браузеров, v2.16) Запрошен README с main через GitHub API: 7 браузеров, -SkipDiskCleanup, диаграмма v2.21
Доверять включённому Storage Sense и не запускать cleanmgr Вооружаемые категории включают Update Cleanup, дампы, Language Pack, ChkDsk, WER - Storage Sense их не трогает

Ivan Bondarev added 11 commits July 23, 2026 11:13
…вершения

Два подтверждённых замечания внешнего ревью.

1. Заголовок лога писался двумя голыми Out-File ДО основного try/finally.
   Замер опроверг ожидание, что это non-terminating: шесть из семи плохих
   путей бросают terminating даже при ErrorActionPreference=Continue
   (нет каталога, путь это каталог, недопустимые символы, двоеточие,
   длинный путь, недоступный UNC). Брошенное там исключение уходило из
   Start-WinClean мимо всех гарантий: ни result JSON, ни сводки, ни самого
   обслуживания - из-за лог-файла.
   Запись в файл вынесена в Write-LogFileLine, через которую теперь идут и
   заголовок, и Write-Log. Семантика обрезки файла при старте сохранена
   явным -StartNewFile, а не потеряна в рефакторинге.

2. Успешное самообновление завершало процесс через exit, минуя finally, и
   поэтому вручную копировало часть финальных действий. Копий было три
   (finally, самообновление, отказ от перезагрузки), и v2.21 чинил их по
   отдельности дважды. Теперь одна Complete-WinCleanRun с латчем, а
   Invoke-ScriptUpdate возвращает ответ "прогон окончен" вместо exit.
   Код возврата не изменился: точка входа и так выводит его из ErrorsCount.

Побочный выигрыш: успешный путь обновления раньше был непроверяем в принципе
(вызывал exit и убил бы прогон Pester) - теперь покрыт.

Тесты 573 -> 607. Мутационная проверка: 13 мутаций, все пойманы. Три из них
сначала выжили и вскрыли слабости самих тестов - в том числе то, что
Should -Invoke -Times N при N>0 означает "не менее N" и не видит дубля.
MyAI-zfwv (P1). Найдено на живой машине: cleanmgr /sagerun делает работу за
~10 секунд, закрывает окно и остаётся резидентным - CPU и все три счётчика
I/O заморожены, шесть потоков в Wait. Прогон ждал оставшиеся ~890 секунд и
затем публиковал ЗАВЕРШЁННУЮ очистку как частичную (DiskCleanupPending).

Обе половины неверны, и вторая хуже: это тот же класс нечестного отчёта,
который вычищали v2.20 и v2.21, только вывернутый - не успех, которого не
было, а незавершённость, которой не было.

Ожидание на HasExited было неверной моделью "работа закончена". Добавлен
второй, независимый признак завершения: полная неподвижность. Нет прироста
CPU и ни одной операции ввода-вывода на протяжении 12 проверок подряд (120
секунд) - работа окончена, независимо от того, вышел процесс или нет.

Безопасность решения:
- неизмеримость НЕ накапливается в вывод о завершении. Отпечаток нечитаем
  (сломан WMI, процесс исчез) -> стрик сбрасывается. Иначе машина со сломанным
  WMI обрезала бы каждую очистку через две минуты и называла её успешной;
- стрик требует ПОДРЯД идущей неподвижности, пауза в работе его сбрасывает;
- цена ошибочного вердикта ограничена: подчистка StateFlags по-прежнему не
  трогает процесс, который не вышел.

Отпечаток строится на Win32_Process: у System.Diagnostics.Process счётчики
Read/Write/OtherOperationCount пусты (замерено), остался бы только CPU.

Result JSON получил DiskCleanupStatus: 'completed-resident' отличает измеренный
случай от настоящего перебора по времени ('timeout'), который по-прежнему
ставит DiskCleanupPending. Булев флаг покрывал две разные истины - лечится
статусом, а не более хитрым булевым (тот же приём, что AppUpdatesStatus в 2.21).

Ожидание вынесено в Wait-CleanmgrCompletion с инъекцией зависимостей, как
Wait-StorageSenseTask: логика теперь проверяема без процесса и без 15 минут.
Это и есть причина, по которой дефект дожил до релиза - на стенде cleanmgr
выходит нормально.

Плюс install.ps1: непрочитанная версия pwsh больше не трактуется как
разрешение. Тот же fail-open, что прятался в проверке SHA256 до v2.17.

Тесты 607 -> 630. Мутаций 9, все пойманы; одна сначала выжила и вскрыла, что
ветка catch (сломанный WMI) не была покрыта вовсе.
Ярлык открывает conhost в его дефолтных 120 колонках, а таблица winget на
локализованной системе требует около 140 - она переносилась, и каждая
перенесённая строка ложилась поверх собственного вывода скрипта. Замер на
машине, где это нашли: окно 120x30, буфер 120x9001, физический максимум 3824
колонки. Место было, никто его не просил.

1. При старте окно расширяется best-effort (буфер раньше окна - иначе нельзя,
   не выше физического максимума). Именно так, а не записью консольных свойств
   в .lnk: ярлык починил бы только ярлык, а NT_CONSOLE_PROPS это двоичный блоб,
   который WScript.Shell не пишет. Windows Terminal откажет - это нормально,
   отказ проглатывается: косметика не имеет права ронять обслуживание.
2. Ширина рамок больше не литерал 70 в четырёх местах, а одна величина,
   выведенная из фактической консоли: max(70, min(90, ширина-6)). Ограничена с
   обеих сторон - уже 70 не опускается (так выглядели все прошлые версии), выше
   90 не поднимается (просили "немного пошире", а не "во весь экран").

Попутно найден скрытый дефект баннера: он был одной here-string, чьи отступы
подогнаны вручную под ЧЕТЫРЁХСИМВОЛЬНУЮ версию. Строка заголовка занимала ровно
70 колонок только потому, что "2.21" это четыре символа - версия 2.5 или 2.100
сдвинула бы её границу относительно остальной рамки. Баннер теперь собирается
программно, логотип центрируется как единый блок (построчное центрирование
разъехало бы буквы), заголовок центрируется по фактической ширине.

Проверка геометрии в смоуке идёт с перенаправленным выводом, где ширина
неизвестна и рамка равна историческим 70 - то есть широкую рамку она не
проверяла бы вообще. Добавлен прогон Test-BoxGeometry на 70/74/80/90 прямо в
тестах, плюс якорь на историческую разметку при 70.

Тесты 630 -> 658. Мутаций 8, все пойманы; из них четыре сначала выжили и
вскрыли, что не проверялись выравнивание блока, центрирование заголовка и
граница физического максимума. Ещё одна оказалась эквивалентной - записана в
мутационном наборе как таковая, а не "починена" подгонкой теста.
… аудита

Версия поднята до 2.22 во всех местах (гейт подтверждает 10/10), CHANGELOG
получил раздел 2.22, счётчики тестов сведены к фактическому прогону (668).

Storage Sense (MyAI-1qtn): лог теперь различает "выключен в Параметрах" и
"включён, отработал, но чистить было нечего". На регулярно обслуживаемой
машине происходит второе - каждый раз, и это выглядело как неисправность.

🔴 Само РЕШЕНИЕ намеренно не тронуто. Предложение из задачи - доверять
включённому Storage Sense и не запускать cleanmgr - проверено по списку
вооружаемых категорий: туда входят Update Cleanup, дампы памяти, Language
Pack, старые ChkDsk и Windows Error Reporting, и ничего из этого Storage
Sense не делает вообще. Обслуживаемая машина молча перестала бы их чистить.
Настройка читается только для сообщения; на это есть тест-страж.

Проверено на живой 25H2 (build 26200): ключ StoragePolicy\01 существует и не
переименован. Читается из HKCU, поэтому под SYSTEM недоступен - функция
трёхсостоянийная и при сомнении отвечает "не знаю", а не "выключено".

Из внешнего аудита документации принято: SUPPORT.md (маршрутизация обращений
и минимальный набор данных), шаблон release notes в docs/release-process.md,
секция "что принимается и по каким критериям" в CONTRIBUTING.

Отклонено с обоснованием: публичный roadmap с колонками статусов (7 звёзд, 0
форков - дублирования feature requests, которое он лечит, не существует; это
ровно тот артефакт, что гниёт молча), отдельный CODE_OF_CONDUCT (аудитор сам
ставит условие "если готовы модерировать"), и главное замечание про якобы
отстающий публичный README - опровергнуто запросом README с main через
GitHub API.

Скобки в report-гварде Invoke-StandTest.ps1: читаемость, не фикс. Приоритет
операторов проверен AST и таблицей истинности, ReportNoCleanup был защищён и
раньше. Таблица истинности закреплена тестом, чтобы вопрос не поднимался
третий раз.
Каждый раунд находил дефекты в фиксах предыдущего - ровно как в v2.20 и v2.21.

Раунд 1 (три находки):
- РЕГРЕССИЯ, внесённая моим же рефакторингом: Wait-ForKeyPress оказался ВНУТРИ
  Invoke-ScriptUpdate, то есть до записи result JSON. В v2.21 JSON писался ДО
  паузы. Брошенное или прерванное окно оставляло прогон без артефакта и с
  неосвобождённым лог-хендлом. Пауза перенесена к вызывающему, после
  Complete-WinCleanRun;
- неподвижность одного нашего PID не доказывает, что работа окончена;
- тест обещал "рамка всегда влезает в консоль", проверяя только широкие окна.

Раунд 2 (мой фикс оказался хуже проблемы):
- агрегирование отпечатка по ИМЕНИ процесса захватывало ЧУЖОЙ cleanmgr,
  запущенный пользователем вручную. Он мог и подтвердить "активность была", и
  своей занятостью держать нас все 15 минут после того, как наша очистка
  закончилась. Сужено до нашего дерева процессов (дети по ParentProcessId, любого
  имени, seen-set против цикла при переиспользовании PID);
- Measure-Object -Sum возвращает Double и теряет точность после 2^53 - суммы
  считаются [long] (есть тест на 2^53+1).

Раунд 3-5 (формулировки, и это не мелочь):
- 'completed-resident' утверждал завершение, которого мы НЕ наблюдали. Мы
  наблюдали неподвижность. Статус переименован в 'idle-resident', лог и доки
  говорят о наблюдении, а вывод назван выводом. Это ровно тот класс, ради
  которого прошли три предыдущих релиза;
- контракт DiskCleanupPending переопределён честно: true = очистка была ВИДИМО
  занята на момент таймаута; false прямо помечен как НЕ доказательство того, что
  больше ничего не будет удалено;
- пределы идентификации дерева процессов (переиспользование PID добавит чужого,
  worker через сервис будет пропущен) записаны как принятое ограничение.

🔴 Граница проведена осознанно: правились утверждения об АЛГОРИТМЕ, а описания
ИЗМЕРЕННОГО случая на конкретной машине оставлены - это факт, а не обещание, и
без него теряется причина всей работы.

Тесты 668 -> 677.
Счётчик вырос за раунды ревью (668 -> 677). Считается прогоном Pester, не
грепом: блоки -ForEach размножаются, наивный подсчёт занижает.
SUPPORT.md, заведённый в этом же релизе, молча оказался ВНЕ гвардов: новый
пользовательский документ со ссылками был единственным, который никто не
проверял ни на тире, ни на битые ссылки. Список, написанный руками, покрывает
только то, что кто-то вспомнил, и следующий файл забылся бы так же.

CLAUDE.md исключён осознанно: это runbook для мейнтейнера и агента, а не
пользовательская документация и не часть публичного контракта.

Тесты 677 -> 679.
🔴 ИНЦИДЕНТ ПРОЦЕССА, а не кода. Пока агент-ревьюер держал рабочее дерево под
мутационным прогоном, я сделал коммит eeba199 через git add -A. В него попали:
- МУТАЦИЯ продукта: из единственного места вызова пропал -StartNewFile, то есть
  лог перестал обрезаться при старте. Прогон с фиксированным -LogPath склеивал
  бы все запуски в один файл - ровно то свойство, которое комментарий параметра
  объявляет сохранённым намеренно;
- WinClean.ps1.mutbak, 6840 строк, полная копия единственного файла кода.
  Паттерн *.bak в .gitignore не покрывает .mutbak.

Коммит назывался "test: гварды документации" и правил WinClean.ps1 - это было
видно в git show --stat с первого взгляда, и не было замечено.

🔴 Регрессию не поймал НИКТО из автоматики: 679 тестов зелёные, смоук зелёный,
стенд бы не увидел (дефолтный лог-путь со штампом времени + откат снапшота),
гейт зелёный (дерево чистое, потому что мутация ЗАКОММИЧЕНА). Существующий тест
проверяет, что Write-LogFileLine честно обрабатывает -StartNewFile, и он
проходит: сломан был не он, а ВЫЗЫВАЮЩИЙ. Тест на место вызова добавляется
отдельным коммитом.

Найдено независимо тремя ревьюерами из четырёх.
…ивший)

Второй движок ревью нашёл то, чего не увидели пять раундов Codex - ровно как в
v2.21. Главное: ФИЧА РЕЛИЗА НЕ ИМЕЛА ПОВЕДЕНЧЕСКОГО ПОКРЫТИЯ ВООБЩЕ.

Код:
- sawActivity открывался ИДЕНТИЧНОСТЬЮ, а не работой. В отпечаток входят число
  процессов и список PID (правильно - это изменение состояния и должно сбрасывать
  стрик), но появление дочернего процесса не является работой. cleanmgr, который
  породил помощника и заблокировался навсегда, проходил в ранний вердикт.
  Отпечаток разделён на части активности и идентичности; гейт смотрит первую;
- DiskCleanupStatus оставался 'not-run' всё время работы cleanmgr (до 900 с). При
  Ctrl+C или исключении JSON сообщал "шаг не выполнялся", пока elevated-процесс
  удалял файлы. Введён 'running' сразу после старта;
- 'failed' покрывал три разные истины -> 'not-armed' / 'start-failed' /
  'exit-nonzero'. Только последняя означает частично очищенную машину;
- -ReportOnly давал 'not-run', документированный как "шаг не выполнялся, например
  прогон был прерван". Это самый частый путь (смоук и два режима стенда) ->
  'skipped-report-only';
- ветка timeout утверждала "всё ещё работает", хотя туда же попадают случаи, когда
  активность не измерялась вовсе. Та же ложь, что я убрал из соседней ветки;
- finally обещал, что флаги реестра подчистит следующий прогон. Не подчистит:
  подчистка гейтится на отсутствие cleanmgr, а резидентный живёт до перезагрузки.
  Обещание убрано. Подчищать здесь СОЗНАТЕЛЬНО не стали: если вывод о
  неподвижности неверен, снятие флагов может оборвать живую очистку;
- удалена мёртвая Get-ProcessActivityFingerprint (её тесты создавали иллюзию
  покрытия), исправлено ложное утверждение в docstring Get-ConsoleWidth.

🔴 ЗАМЕР ОПРОВЕРГ МОЁ УТВЕРЖДЕНИЕ: перенаправление вывода НЕ обнуляет ширину
консоли - RawUI читает подключённую консоль, а не stdout. Смоук всё это время
проверял ширину 90, а мой комментарий и CHANGELOG утверждали обратное.

Тесты (ложно успокаивали, проверено мутациями):
- DiskCleanupStatus: 7 присваиваний, дефолт и само поле в JSON можно было удалить
  - все тесты зелёные. Добавлено поведенческое покрытие;
- страж "решение не зависит от настройки" охранял НЕ ТУ функцию: реальный реверс,
  вставленный в Invoke-StorageSense, выжил;
- кейс report-only никогда не входил в свою ветку: локальная $ReportOnly=$false в
  теле It перекрывала $script: через динамический скоуп. Плюс утечка состояния;
- адаптивную ширину можно было отключить целиком, а четырёх потребителей рамок
  вернуть к литералу 70 - геометрия проверяет боксы поштучно, не сравнивая между;
- порог 12 проверок не был запиннен (все тесты передают его явно);
- -StartNewFile не проверялся в месте вызова - та самая мутация, что уехала в
  коммит;
- Should -Match 'Write-Host' ловил комментарий "Deliberately Write-Host".

Тесты 679 -> 700.
Матчер прогоняет каждый паттерн через [regex]::Escape, поэтому написанный
руками \. искался как обратный слеш. Новая проверка молча не срабатывала бы -
ровно тот класс, ради которого её и добавляли.
… лексика

Финальный раунд Codex по правкам агентов нашёл ещё два.

1. -SkipCleanup подавляет ВСЮ фазу DeepSystemCleanup, поэтому Invoke-StorageSense
   не выполняется и статус не присваивается - оставался дефолт 'not-run', который
   схема описывает как "прогон прервался, не дойдя". Обычный осознанный пропуск
   читался как авария. Введено 'skipped-cleanup-group', присваивается там же, где
   AppUpdatesStatus для -SkipUpdates. Тест round-trip этого не видел: он
   присваивает значения искусственно, а не проходит боевой путь.

2. Лексика статусов осталась старой в трёх местах (комментарий New-RunStats,
   CHANGELOG, docstring DiskCleanupPending), плюс комментарий v2.20 всё ещё
   обещал подчистку "следующим прогоном" - ровно то обещание, которое я убрал из
   лога строкой ниже.

Попутно починен хрупкий тест v2.21: он искал присваивание в окне 400 символов
перед диспетчеризацией фазы, и мой соседний блок вытолкнул строку за окно.
Проверяется ПОРЯДОК (присваивание раньше диспетчеризации), а не близость.

Тесты 700 -> 702.
@bivlked
bivlked merged commit cdaf897 into main Jul 23, 2026
5 checks passed
@bivlked
bivlked deleted the feature/v2.22 branch July 23, 2026 15:32
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant