ADR 0176 — SessionPage.tsx меньше 1 000 строк, за десять механических PR, а замок — только после
Статус
Accepted. 2026-09-28 (AT-138, BRB-015). Решение сопровождающего
(2026-09-27): да, и запереть после — декомпозировать
apps/web/src/routes/SessionPage.tsx до менее чем 1 000 строк механическими
PR и только затем превратить потолок в замок CI.
Ссылается, без правки, на ADR 0122
(пять механических PR, которые довели файл с 3 807 до 2 661 строки и решили,
что извлечённые соседние файлы импортируют SessionPage.module.css напрямую),
ADR 0124 (кластер канала хода в
useTurnoDoAgente, который эта программа НЕ трогает) и
ADR 0125 (та же мерка
«импортёры ничего не меняют», применённая к ProjectSettingsTab.tsx).
Контекст
Строка долга SessionPage.tsx закрылась 2026-08-30, ADR 0122 и 0124, на
2 479 строках. Она закрылась по ЧИСЛУ, и ничто потом это число не
измеряло: 2026-09-13 в файле было 2 559 строк (architecture.md стала это
объявлять, AT-031), а в сегодняшней origin/dev (018b8cd24c, измерено
wc -l) — 2 637 строк и 122 379 байт. Никто не сделал ничего
неправильного — каждое добавление было законной функциональностью (фильтр
пригодных, карточка handoff Infra, отказ закрытой сессии, Infra Lead в
composer), — и именно поэтому регрессия и произошла: без замка долг
возвращается как сумма правильных решений.
Каким был файл, измеренный до этой программы:
- Компонент на ~2 400 строк. Тело
SessionPageшло со строки 237 по 2 637. Вне его были только импорты, реэкспорт функций timeline иagruparNarracoesDoTurno. - Самым большим блоком был
useMemoна 762 строки, сборка timeline: по одному JSX-узлу на тип события, партия историй, ожидающих продвижения (RN-126/RN-148), и карточки одобрения (RN-155). - Возвращаемый JSX занимал ~625 строк в трёх независимых полосах: верхняя панель, прокручиваемое содержимое ленты и нижняя полоса (карточка Infra, ручной handoff, composer).
- Обработчики занимали ~500 строк, в трёх семействах: обработчики ХОДА
(
handleSend,handleCancel,handleReadiness,handleArchitectureReadiness— вход в кластер ADR 0124), обработчики ПРОДВИЖЕНИЯ историй и обработчики HANDOFF/ИСПОЛНЕНИЯ, не относящиеся к ходу. - 28 файлов
SessionPage.*.test.tsx, 173 теста, все сvi.mockМОДУЛЯ (../lib/api-client,../lib/hooks,../lib/session-channel…). Mock модуля действует для любого файла, импортирующего модуль, поэтому код, перенесённый в соседний файл, остаётся замоканным — именно это делает перенос без правки тестов осуществимым.
Решение
Десять PR, уложенных стопкой по порядку (каждый поверх предыдущего,
--base — ветка предыдущего), каждый МЕХАНИЧЕСКИЙ: перенос кода без изменения
поведения — никакого рефакторинга логики, переименования публичного API
компонента или «улучшения мимоходом». Мерка приёмки — из ADR 0122, слово в
слово: 28 существующих тестовых файлов проходят БЕЗ ПРАВКИ. PR, которому
нужно править один из них, не механический и останавливается.
SessionPage.tsx уходит с 2 637 и приходит к 834 строкам (−1 803):
| PR | Что уходит | Куда | SessionPage.tsx после |
|---|---|---|---|
| — | ADR 0176 (этот документ) | docs/adr/ | 2 637 |
| 1 | agruparNarracoesDoTurno, FIO_RECENTES_ABERTAS, тело useMemo timelineAgrupada (сворачивание по агенту, RN-138) и fio (срез по источнику, RN-177) | routes/session-fio.tsx (agruparTimelinePorAgente, dividirFio) | 2 445 (−192) |
| 2 | тело useMemo timeline (762 строки) | routes/session-timeline-montagem.tsx (montarTimeline) | 1 718 (−727) |
| 3 | верхняя панель | routes/SessionTopbar.tsx | 1 561 (−157) |
| 4 | содержимое ленты (приглашение, история, недавние, оптимистичное, пузырь streaming) | routes/SessionFio.tsx | 1 390 (−171) |
| 5 | нижняя полоса (карточка Infra, ручной handoff, composer) | routes/SessionComposer.tsx | 1 225 (−165) |
| 6 | activeFor, offeredHandoff, handoffDaInfraOferecido и три «уже объявленных» | lib/session-handoffs.ts (derivarHandoffsDaSessao) | 1 163 (−62) |
| 7 | четыре ref и четыре эффекта прокрутки, и rolarParaOFim | lib/session-rolagem.ts (useRolagemDoFio) | 1 094 (−69) |
| 8 | пять состояний и три обработчика продвижения историй, и модальное окно возврата | lib/session-promocao.ts (usePromocaoDeHistorias) + routes/DevolverHistoriaModal.tsx | 982 (−112) |
| 9 | четыре состояния и пять обработчиков handoff/исполнения, не относящихся к ходу (RN-406, RN-440, RN-161, RN-137, RN-153) | lib/session-acoes-de-handoff.ts (useAcoesDeHandoff) | 834 (−148) |
| 10 | замок | routes/SessionPage.teto.test.ts | 834 |
Порядок идёт от наименее рискованного к наиболее, как в ADR 0122: сначала то, что является чистой функцией или почти (PR 1–2), затем JSX без состояния (PR 3–5), затем чистое вычисление (PR 6), и в последнюю очередь ХУКИ (PR 7–9), которые единственные трогают порядок хуков компонента.
Что значит «механический» для каждого вида среза, решено здесь один раз, чтобы не пересуживать в десяти ревью:
- Тело
useMemoстановится функцией, вызываемой ИЗНУТРИ того жеuseMemo, с ТЕМ ЖЕ списком зависимостей, байт в байт — включая то, чего он не перечисляет. Сборка timeline замыкается на обработчики (handlePromoteStory,handleAcceptHandoff…),tиsemRepositorio, которых никогда не было в зависимостях; дополнение списка изменило бы, КОГДА timeline пересобирается, а это поведение.oxlintпродолжает предупреждать (exhaustive-deps— этоwarn) о том, о чём предупреждал раньше. - JSX-блок становится компонентом, у props которого ТЕ ЖЕ имена, что
блок читал из области видимости
SessionPage. Компонент вызывает собственныйuseTranslation('sessionPage'). Блок с несколькими соседями становится фрагментом.{recusandoStory && …}модального окна ушёл вместе с ним, внутрь компонента. - Чистое вычисление становится чистой функцией, возвращающей те же имена и вычисляемой на каждом рендере, как и было.
- Состояние + обработчики становятся хуком, вызываемым в ТОЙ ЖЕ точке,
где были эффекты (PR 7), или сразу после
useTurnoDoAgente, когда обработчики от него зависят (PR 8–9). ПозицияuseStateв порядке хуков меняется, и это не наблюдаемо: React требует лишь одинакового порядка между одним рендером и следующим. Обработчики остаются функциями, пересоздаваемыми на каждом рендере, как и былиasync functionкомпонента. - Без JSX — в
lib/*.ts; с JSX — остаётся вroutes/*.tsxи импортируетSessionPage.module.cssнапрямую — ответ ADR 0122 на вопрос CSS, переиспользованный без повторного открытия. Вlib/по-прежнему нет.tsxс кодом. - Комментарии уходят вместе с кодом, включая говорящие «здесь» или «чуть ниже», — новый файл в начале говорит, откуда они пришли. Переписать комментарий мимоходом — это то «улучшение мимоходом», которое программа запрещает.
SessionPage.tsxреэкспортирует то, что тесты из него импортируют:agruparNarracoesDoTurnoушла вsession-fio.tsxи по-прежнему реэкспортируется, по той же причине, что и четыре функции timeline из ADR 0122.
Замок рождается в ПОСЛЕДНЕМ PR, а не раньше. Это тест web
(SessionPage.teto.test.ts), проваливающий SessionPage.tsx при 1 000 строк
или больше, считая как wc -l, и доказывающий собственный подсчёт на обеих
границах (999 проходит, 1 000 проваливается) — измерено также мутацией,
добавлением 166 строк в реальный файл. Он живёт в web, а не в scripts/ci/,
потому что выполняется в наборе, который уже запускает каждый PR, трогающий
файл.
Вне рамок, намеренно
- Обработчики ХОДА (
handleSend,handleCancel,handleReadiness,handleArchitectureReadiness,avisarSessaoEncerrada) и обработчики жизненного цикла сессии (handleActivate,handleClose,handleRename,handleStartIdeation) остаются вSessionPage. Обработчики хода — входная дверь кластера, который ADR 0124 перенёс вuseTurnoDoAgenteи оставил вызываемым ОТСЮДА; их вынос означал бы заново решать, где живёт ход, а это не механика. Потолок достигнут без них. useTurnoDoAgente,useSessionReadiness,ContextAside,StructuredQuestionCard,StorySlide— уже извлечены, не меняются.- Ни один список зависимостей не «исправляется», ни один обработчик не
становится
useCallback, ни одно имя prop или функции не меняется. Каждое из этого было бы изменением поведения или API, и каждое, если оно того стоит, — отдельный PR, который не выдаёт себя за механический.
Последствия
- Файл уходит с 2 637 до 834 строк, с запасом в 166 до замка. Запас намеренный: потолок не может стать следующей функциональностью, которая в него упадёт. Кому нужно перейти 999, делает срез — кандидаты объявлены выше, — и никогда не поднимает число. Поднять потолок — это новый ADR, ссылающийся на этот.
- Сумма строк РАСТЁТ: в десяти новых файлах 2 574 строки против 1 803 ушедших, потому что каждый получил собственные импорты, блок props или контекста и заголовок, говорящий, откуда он пришёл. Это цена явных границ, и ту же цену принял ADR 0125.
- Самый большой новый файл — 861 строка (
session-timeline-montagem.tsx, почти весь исходныйuseMemo). Здесь он замка НЕ получает: объявленный долг — это долгSessionPage.tsx, и запереть только что родившийся файл было бы той ошибкой, которую назвала AT-031. Если он вырастет, следующий естественный срез — по файлу на семейство событий. - Ссылки
arquivo:linhaнаSessionPage.tsxвdocs/business-rules.mdиdocs/business-rules/autenticacao.mdсместились — многие были устаревшими ещё до этой программы. Они обновляются в PR 10, один раз, когда строки перестают двигаться; в PR 1–9 предупреждаетverificarRefsComSimbolo(вwarn). BRB-015перестаёт воспроизводиться в PR 10, и строкаarchitecture.md, объявлявшая регрессию, закрывается вместе с ним, указывая на замок.- Уложенные стопкой PR сливаются по порядку. PR функциональности,
затронувший
SessionPage.tsxмежду двумя из них, делает rebase поверх среза — цена, которую ADR 0122 уже принял за то, чтобы не замораживать файл.
Отвергнутые альтернативы
- Запереть до декомпозиции, с потолком на сегодняшнем числе, снижаемым с каждым PR. Отвергнуто сопровождающим: потолок, следующий за снижением, — это число, которое нужно править в каждом PR программы, а фиксированный потолок выше файла ничему не мешает. Замок имеет ценность только как НЕТ на «снова начал расти», а это существует лишь после того, как цель достигнута.
- Один PR. Отвергнуто по той же причине, что в ADR 0122: 1 800 строк, перенесённых в одном diff, не ревьюятся, а файл продолжает получать функциональность.
- Унести вместе обработчики хода, чтобы зайти дальше. Отвергнуто: это кластер, о котором решил ADR 0124, и потолку он не понадобился.
- Файл на тип события уже в PR 2. Отвергнуто для этой программы: разбиение
useMemoна функции по событиям требует решить сигнатуру каждой (партия продвижения иcontinueцикла пересекают типы), и это перестаёт быть переносом. Сначала перенести тело целиком — то, что позволяет этому срезу, если он будет, ревьюиться отдельно.