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 нет ни одного глагола
записи, делает эту границу проверяемой, а не намерением.