Перейти к основному содержимому

ADR 0060 — Поверхность чтения кода, ограниченная и с бюджетом

  • Статус: принят
  • Дата: 2026-08-08
  • Предыдущий контекст: ADR 0001 (нормализованный контракт git и что в него входит), ADR 0058 (центральное ограничение путей, RN-092), ADR 0055 (примитивы области, которые переиспользует это ограничение)

Контекст​

FASE 26a добавила listTree и getPullRequestDiff в GitProviderContract, доказанные у трёх providers единой suite. Не хватало потребителя — а замок, установленный самой 26a (SEM_CONSUMIDOR_AINDA), проваливал бы CI, пока его не было.

Потребитель — вкладка Code, и её написание вынудило решить три вещи, которые не решал ни один предыдущий код, потому что ни один маршрут продукта до сих пор не получал путь к файлу от клиента.

Первая — где живёт поиск. Вкладке нужны четыре чтения: дерево, содержимое, поиск и diff PR. Три из них — операции контракта. Поиск не принадлежит ни одному из трёх providers: у GitHub и GitLab есть платформенный code search, с собственной семантикой и пределами; LocalGitProvider — bare repo, и у него ничего такого нет. Объявить search в контракте означало бы либо 13-ю операцию с capability false у local — вкладку, работающую у двух providers и исчезающую у третьего, — либо втащить словарь code search одной платформы внутрь нормализованного контракта, который существует именно чтобы форма Octokit не просачивалась.

Вторая — стоимость. Чтение дёшево за вызов и дорого при повторении. Дерево и diff уже имеют предел в контракте (26a), но они ограничивают ОДИН ответ; составной поиск делает N вызовов, и N растёт с размером репозитория, а не с размером запроса. Платят учётные данные владельца workspace (RN-058/RN-082), а rate limit — у provider. Продукт уже видел это семейство дефектов вблизи: дашборд делал 3.824 запроса в минуту, потому что каждый проект запрашивал свой (RN-090), и итоговый 429 превращался в белый экран.

Третья — путь. ../../etc/passwd в query string приходит в handler уже декодированным Express. В github/gitlab этот путь становится сегментом URL API provider, так что .. не читает не тот файл: он меняет endpoint, с токеном владельца в руке. В local он становится правой частью git show <ref>:<path>. Это тот же класс проблемы, который ADR 0058 закрыл для projectId, применённый теперь к пути файла, — а FASE 14d уже показала режим сбоя, которого важно избежать: тестировать деталь — не значит тестировать путь до неё.

Решение​

Поиск остаётся ВНЕ контракта git, составляется на уровне приложения, с бюджетом. ReadProjectCodeUseCase обходит дерево в ширину, вызывая listTree, и открывает файлы через getFileContent. Три бюджета его останавливают — пройденные каталоги, открытые файлы, возвращённые совпадения, — а truncated говорит, что он остановился. В ширину, а не в глубину, потому что, прерванный посередине, он отдаёт самые неглубокие файлы, куда ищущий обычно и смотрит. Кэш с коротким TTL (30 с), ограниченный числом записей, не даёт навигации и поиску повторять одни и те же вызовы; короткий, потому что вкладка читает живую ветку, а длинный TTL показывал бы уже изменившийся код.

Ограничение пути — ОДНА функция, в файле RN-092. caminhoDeRepositorioContido() привязывает путь клиента к папке проекта через projectScopeRoot() и переиспользует normalizarCaminho/dentroDoEscopo из ADR 0055. Она возвращает нормализованный путь, и вызывающий использует вернувшееся — возврат исходного позволил бы проверить одну строку и отправить provider другую. Абсолютный путь отклоняется, даже когда имя существовало бы внутри репозитория: переинтерпретация начальной косой черты была бы тихим преобразованием. ref проверяется в том же месте, и .. в нём отклоняется, потому что для git dev..main — это диапазон коммитов, а не ревизия. Ни один маршрут не валидирует путь самостоятельно.

Четыре маршрута — GET, role:viewer, и у controller нет глагола записи. Видеть код проекта — то же разрешение, что видеть проект. Чтение не становится proposed_action: оно не является внешним эффектом, и превращение его в действие одобрения забило бы очередь шумом, пока никто не стал бы читать настоящие. Используемые учётные данные — владельца, тем же резолвером, что и запись.

Последствия​

CodeQL продолжит указывать, и это принятая цена. Барьер, живущий в другой функции, он не видит — именно поэтому три js/path-injection пережили исправление PÓS-FASE 15, и решением тогда было сохранить центральную проверку и платить на панели. Здесь оно не меняется: дублирование ограничения в каждом маршруте заглушило бы предупреждение и создало бы четыре копии, которые однажды разойдутся. Новое предупреждение этого пути снимается с письменным обоснованием, никогда молча.

Поиск — не поиск GitHub, и не должен притворяться им. Он не индексирует, не понимает синтаксис, не сортирует по релевантности, а в большом репозитории он ОБРЕЗАЕТ. Контракт с потребителем — truncated + filesScanned: экран говорит, что было обрезание, и предлагает уточнить path. Замена этого на платформенный code search — будущее решение, и ему пришлось бы сначала решить асимметрию provider local.

Кэш общий для всех, у кого есть доступ к одному проекту. В ключе нет пользователя, потому что пришедший уже прошёл role:viewer маршрута, а репозиторий один и тот же для всех них. Если когда-нибудь чтение начнёт зависеть от того, КТО читает, ключ должен получить это измерение, — это написано в коде, потому что это тот тип предпосылки, который исчезает.

Окно в 30 с — видимая задержка. Push, сделанный вне продукта, может появиться во вкладке с задержкой до полуминуты. Настоящая инвалидация потребовала бы знать, когда репозиторий изменился, а у продукта нет этого сигнала для внешнего push; короткий TTL — честный выбор, пока его нет.

Объявлено вне области: подсветка синтаксиса (новая зависимость, пункт 35 фазы, решение 26c), интерактивный терминал (зависит от контейнера на проект FASE 25) и любая запись через вкладку. Когда придёт запись, она родится proposed_action — и то, что у этого controller нет ни одного глагола записи, делает эту границу проверяемой, а не намерением.