Pular para o conteúdo principal

ADR 0060 — A superfície de leitura de código, contida e com orçamento

  • Status: aceito
  • Data: 2026-08-08
  • Contexto anterior: ADR 0001 (o contrato normalizado de git e o que entra nele), ADR 0058 (a contenção de caminho central, RN-092), ADR 0055 (as primitivas de escopo que esta contenção reusa)

Contexto​

A FASE 26a acrescentou listTree e getPullRequestDiff ao GitProviderContract, provadas nos três providers pela suite única. Faltava o consumidor — e a trava que a própria 26a instalou (SEM_CONSUMIDOR_AINDA) reprovaria o CI enquanto ele não existisse.

O consumidor é a aba Code, e escrevê-lo obrigou a decidir três coisas que nenhum código anterior tinha decidido, porque nenhuma rota do produto até aqui recebeu caminho de arquivo do cliente.

A primeira é onde mora a busca. A aba precisa de quatro leituras: árvore, conteúdo, busca e diff de PR. Três são operações do contrato. A busca não é de nenhum dos três providers: GitHub e GitLab têm code search de plataforma, com semânticas e limites próprios; o LocalGitProvider é um bare repo e não tem nada disso. Declarar search no contrato significaria ou uma 13ª operação com capability false no local — uma aba que funciona em dois providers e some no terceiro — ou importar o vocabulário de code search de uma plataforma para dentro do contrato normalizado, que existe justamente para não deixar o shape do Octokit vazar.

A segunda é o custo. Ler é barato por chamada e caro por repetição. A árvore e o diff já têm teto no contrato (26a), mas eles limitam UMA resposta; a busca composta faz N chamadas, e N cresce com o tamanho do repositório e não com o tamanho do pedido. Quem paga é a credencial do owner do workspace (RN-058/RN-082), e o rate limit é do provider. O produto já viu essa família de defeito de perto: o dashboard fazia 3.824 requisições por minuto porque cada projeto pedia a sua (RN-090), e o 429 resultante virava tela branca.

A terceira é o caminho. ../../etc/passwd numa query string chega ao handler já decodificado pelo Express. Em github/gitlab esse caminho vira segmento de URL da API do provider, então um .. não lê o arquivo errado: ele troca de endpoint, com o token do owner na mão. Em local ele vira o lado direito de git show <ref>:<path>. É a mesma classe de problema que o ADR 0058 fechou para o projectId, aplicada agora ao caminho de arquivo — e a FASE 14d já ensinou o modo de falha que interessa evitar: testar a peça não é testar o caminho até ela.

Decisão​

A busca fica FORA do contrato de git, composta na camada de aplicação, com orçamento. ReadProjectCodeUseCase varre a árvore em largura chamando listTree e abre arquivos com getFileContent. Três orçamentos a param — diretórios percorridos, arquivos abertos, casamentos devolvidos — e truncated diz que ela parou. Largura e não profundidade porque, cortada no meio, ela entrega os arquivos mais rasos, que é onde quem busca costuma olhar. Um cache de TTL curto (30s), limitado em número de entradas, evita que navegar e buscar repitam as mesmas chamadas; curto porque a aba lê uma branch viva, e um TTL longo mostraria código que já mudou.

A contenção de caminho é UMA função, no arquivo da RN-092. caminhoDeRepositorioContido() ancora o caminho do cliente na pasta do projeto via projectScopeRoot() e reusa normalizarCaminho/dentroDoEscopo do ADR 0055. Ela devolve o caminho normalizado, e o chamador usa o que voltou — devolver o original permitiria conferir uma string e mandar outra ao provider. Caminho absoluto é recusado mesmo quando o nome existiria dentro do repositório: reinterpretar a barra inicial seria conversão silenciosa. A ref é conferida no mesmo lugar, e .. nela é recusado porque para o git dev..main é intervalo de commits, não revisão. Nenhuma rota valida caminho por conta própria.

As quatro rotas são GET, role:viewer, e o controller não tem verbo de escrita. Ver o código do projeto é a mesma permissão que ver o projeto. Leitura não vira proposed_action: ela não é efeito externo, e transformá-la em ação de aprovação encheria a fila de ruído até ninguém mais ler as de verdade. A credencial usada é a do owner, pelo mesmo resolvedor da escrita.

Consequências​

O CodeQL vai continuar apontando, e isso é o preço aceito. Barreira que mora em outra função ele não enxerga — foi o que fez os três js/path-injection sobreviverem à correção da PÓS-FASE 15, e a decisão de então foi manter a checagem central e pagar no painel. Ela não muda aqui: duplicar a contenção em cada rota calaria o alerta e criaria quatro cópias que um dia divergem. Alerta novo deste caminho se dispensa com justificativa escrita, nunca em silêncio.

A busca não é a busca do GitHub, e não deve fingir que é. Ela não indexa, não entende sintaxe, não ordena por relevância, e num repositório grande ela CORTA. O contrato com quem consome é truncated + filesScanned: a tela diz que houve corte e sugere refinar o path. Trocar isso por code search de plataforma é decisão futura, e teria de resolver a assimetria do provider local antes.

O cache é compartilhado entre quem tem acesso ao mesmo projeto. A chave não tem usuário, porque quem chega já passou pelo role:viewer da rota e o repositório é o mesmo para todos eles. Se algum dia a leitura passar a depender de QUEM lê, a chave precisa ganhar essa dimensão — está escrito no código porque é o tipo de premissa que some.

A janela de 30s é atraso visível. Um push feito fora do produto pode demorar até meio minuto para aparecer na aba. Invalidação de verdade exigiria saber quando o repositório mudou, e o produto não tem esse sinal para push externo; o TTL curto é a escolha honesta enquanto ele não existir.

Fica de fora, declaradamente: destaque de sintaxe (dependência nova, item 35 da fase, decisão da 26c), terminal interativo (depende do container por projeto da FASE 25) e qualquer escrita pela aba. Quando a escrita vier, ela nasce proposed_action — e o fato de este controller não ter um único verbo de escrita é o que torna essa fronteira verificável, e não uma intenção.