Na semana passada, minha IA gerou um código onde ela lia um arquivo JSON do disco, fazia o parse, fazia uma busca e repetia isso 900 vezes dentro de um for. Cada iteração: abrir o arquivo, decodificar o JSON, buscar um valor e descartar tudo. Depois, fazia tudo de novo.

É um erro que eu ensino meus alunos a evitar no primeiro mês de programação.

O que aconteceu (sem enrolação)

Estou desenvolvendo o Tokamak, um aplicativo para barra de menus no macOS que monitora a cota do Claude Max. Parte da funcionalidade escaneia ~900 arquivos JSONL de sessões de Claude Code. Para cada arquivo, o Tokamak precisa saber o byte offset de onde parou na última vez (leitura incremental — só ler o que é novo).

Os offsets são armazenados em um JSON, assim:

1
2
3
4
5
6
7
{
  "version": 1,
  "offsets": {
    "projeto-a/sessao-1.jsonl": 48231,
    "projeto-b/sessao-2.jsonl": 12044
  }
}

Um Dictionary<String, UInt64>. 900 entradas. ~55KB. Nada demais.

Agora vem a parte mais absurda: esse arquivo foi criado pelo próprio app. Não é um JSON de uma API externa, nem veio do Claude Code. É um arquivo de estado interno que o Tokamak escreve e lê para saber onde ele parou a leitura de cada sessão. A IA estava lendo do disco 900 vezes um arquivo que ela mesma tinha gerado.

“Mas por que não usa Core Data ou SQLite, já que eles estão disponíveis no app?” Boa pergunta. Porque aquele arquivo é apenas um cache descartável de progresso. Se corromper, você pode apagar e o próximo escaneamento vai reconstruir todos os offsets lendo os arquivos completos novamente. Sem nenhuma perda real de dados. Além do mais, posso usar cat session-offsets.json | jq . para fazer debug (com Core Data, precisaria usar sqlite3 e o caminho do sandbox). É Sendable sem precisar lidar com contexts em background, e, se o SQLite do Core Data se corromper, isso não impacta os offsets (e vice-versa). Para 55KB de um dicionário simples, criar uma entidade com migração de schema não faz sentido.

O problema não era o formato. Era o acesso.

O código que a IA escreveu para o loop de escaneamento foi assim:

1
2
3
4
5
6
7
8
9
for file in files {  // 900 arquivos
    let storedOffset = offsetStore.offset(for: file.relativePath)
    // ↑ ISSO lê o JSON do disco e faz o parse. Toda. Vez.

    if file.fileSize == storedOffset { continue }
    // ... lê o arquivo, atualiza o offset ...
    offsetStore.setOffset(newOffset, for: file.relativePath)
    // ↑ E ISSO lê o JSON DE NOVO para modificar e salvar.
}

Duas operações de disco por iteração. 900 iterações. 1.800 operações de I/O onde deveria haver exatamente 2: uma leitura no início, uma escrita no final.

Os números (o xctrace não mente)

Entendi o problema usando o Instruments (Time Profiler). Os dados:

MétricaAntesDepois
Samples totais7.260489
Samples em OffsetStore.load()1.704 (88%)10 (2%)
Tempo de escaneamento>20s<0.5s
CPU81%~1.5%

Oito e oito por cento do tempo de escaneamento eram gasto lendo e parseando um JSON de 900 linhas. Repetidamente. Como Sísifo empurrando a pedra, mas com JSONDecoder.

O fix (que deveria te dar vergonha alheia)

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
// ANTES: I/O em cada iteração
for file in files {
    let offset = offsetStore.offset(for: file.relativePath) // lê JSON
    // ...
    offsetStore.setOffset(newOffset, for: file.relativePath) // lê + escreve JSON
}

// DEPOIS: carrega uma vez, opera em memória, salva uma vez
var offsets = offsetStore.load()  // UMA vez
for file in files {
    let offset = offsets.offsets[file.relativePath] ?? 0  // O(1) em memória
    // ...
    offsets.offsets[file.relativePath] = newOffset
}
offsetStore.save(offsets)  // UMA vez

Presta atenção: a estrutura de dados não mudou. Continua sendo um Dictionary<String, UInt64>. A hash table já era ótima. O que não era ótimo era reconstruí-la a partir do disco em cada iteração.

O que não funciona: colocar “não faça isso” no CLAUDE.md

Depois do fix, adicionei este trecho ao arquivo CLAUDE.md do projeto:

“NUNCA faça I/O (disco, rede, decode JSON, Core Data fetch) dentro de um loop se você pode fazer isso antes. Carregue os dados uma vez antes do loop, opere em memória e salve uma vez depois.”

E aqui vem o que eu realmente quero compartilhar: não adiantou nada.

Algumas semanas depois, ao adicionar um segundo serviço (Codex), a IA gerou exatamente o mesmo padrão. Com a instrução bem na frente dela. É como colocar uma placa de “não pise na grama” e esperar que funcione.

Por quê? Porque o LLM não entendeu a regra. Ele viu a regra. Mecanicamente. Estatisticamente, a maior parte do código que ele leu durante o treinamento realiza I/O pontual, não em loops de 900 iterações. O padrão load → use → save em uma função é o mais comum. O fato de essa função ser chamada dentro de um for de 900 iterações é um detalhe de contexto que o modelo não tem incentivo para rastrear.

O que também não funciona: linters

Não existe nenhum linter que detecte isso. Nem SwiftLint, nem ESLint, nem Ruff, nem Clippy. Pense bem: o código é sintaticamente correto e semanticamente válido. Cada chamada individual a offsetStore.offset(for:) é perfeitamente razoável. O problema não está em nenhuma linha — está na composição.

Se olharmos as camadas de significado do código (uma ideia que uso em meu curso sobre desenvolvimento adversarial):

CamadaPerguntaErro aqui?
1. SinalIsso é código?Não
2. LinguagemÉ Swift válido?Não
3. SintaxeCompila?Não
4. Semântica localA função faz o que promete?Não
5. Semântica de sistemaRespeita contratos e desempenho?Sim
6. ArquiteturaEscalável sem degradação?Sim

O erro está nas camadas 5 e 6. Exatamente onde os LLMs falham hoje em 2026. A sintaxe e a lógica local estão impecáveis. O problema é emergente: aparece quando uma função correta é usada em um contexto que a transforma em gargalo.

Linters operam nas camadas 2-4. Eles não conseguem enxergar composição ou desempenho. É como pedir para o corretor ortográfico do Word detectar uma falácia em um argumento.

O que realmente funciona: testes de desempenho

Depois do primeiro fix, escrevi este teste:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
@Test("Desempenho do escaneamento não se degrada com o número de arquivos")
func scanPerformanceDoesNotDegradeWithFileCount() async throws {
    // Criar 1000 arquivos JSONL com conteúdo mínimo
    for i in 0..<1000 {
        let content = "..." // uma linha válida
        try content.write(to: dir.appendingPathComponent("session-\(i).jsonl"), ...)
    }
    // Preencher o offset store com dados de teste simulados
    var offsets = SessionOffsetStore.OffsetData()
    for i in 0..<1000 {
        offsets.offsets["session-\(i).jsonl"] = 100
    }
    offsetStore.save(offsets)

    let start = ContinuousClock.now
    await service.scan()
    let elapsed = ContinuousClock.now - start

    #expect(elapsed < .seconds(3))  // <3s para 1000 arquivos
}

É um teste de regressão brutalmente simples. 1000 arquivos, menos de 3 segundos, ou o teste falha. Se alguém (humano ou IA) voltar a colocar I/O dentro do loop, o tempo passa de 0.2 segundos para 30, e explode.

E foi exatamente isso que aconteceu. Quando a IA gerou o segundo serviço com o mesmo bug, o teste de desempenho do primeiro serviço continuou passando (era um serviço diferente). Mas ao escrever o teste equivalente para o novo serviço, ele falhou imediatamente. O teste fez seu trabalho: detectar a regressão que nem o CLAUDE.md nem qualquer linter conseguiam ver.

O que isso comprova

Esse bug é a demonstração perfeita da tese central do que eu chamo de desenvolvimento adversarial: nunca confie, sempre verifique.

Você não pode confiar que a IA não vai cometer erros básicos. Ela vai cometer. Repetidamente. Mesmo que você diga para não fazer isso.

Você também não pode confiar que os linters vão detectar. Não vão. O erro está acima do nível de abstração deles.

O que você pode fazer:

  1. Testes de desempenho como rede de segurança
  2. Profiling real (xctrace, Instruments) para medir, ao invés de adivinhar
  3. Defesa em profundidade: múltiplas camadas, porque nenhuma camada individual cobre tudo

Explicando de forma simples: a defesa não é uma barreira. É uma cebola — com várias camadas. E quando uma falha, a próxima a captura.

Para os céticos

“Mas Fernando, um programador humano não cometeria o mesmo erro?”

Um programador júnior, sim. Um sênior, provavelmente não — porque já interiorizou o padrão. Mas mesmo um sênior faria code review e perceberia o erro. O problema com código gerado por IA é o volume: 50 arquivos em 10 minutos. Ninguém revisa 50 arquivos linha por linha. A fadiga de revisão é real.

E é por isso que a verificação precisa ser automática, não humana. O teste de desempenho não se cansa. Não se distrai. Não sofre fadiga. Ele roda toda vez que você executa make test e te avisa se algo está errado.

Esse é o mesmo princípio que uso em as 5 defesas contra alucinações: o sistema de verificação deve ser externo ao gerador. Se a IA gera o código, a verificação precisa vir de outro lugar. Nesse caso, de um relógio que mede quanto tempo a execução demora.