SLOPSHOPPER

prova-de-teste

Instrumento do EXP-001 Prova de Teste: percebe tarefas com teste, escolhe mutantes às cegas (modelo sem ferramentas, só o diff do código-fonte) e grava o log…

newguardcommandtoolmodelprocess
v0.16.0MITupdated 2026-10-16aiob3/claude-grounds/prova-de-teste
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · prova-de-teste
› fix the failing auth test and add an audit log call ⏺ Read(src/auth.ts) ⎿ Read 6 lines ⏺ Update(src/auth.ts) ⎿ Added 2 lines, removed 1 line ⏺ Bash(bun test) ⎿ 3 pass, 1 fail ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM › /prova ⎿ prova-de-teste: Escolha: R2.1 às cegas por claude-opus-5-5 via $.model.complete (sem ferramentas, sem histórico). ⎿ prova-de-teste: Enviado: diff de src/auth.ts contra HEAD. Testes retirados antes do envio: nenhum. ⎿ prova-de-teste: Mutantes (0): ⎿ prova-de-teste: nenhum ⎿ prova-de-teste: ⚠ Nenhum arquivo de teste no diff: o EXP-001 vale para tarefas que criam ou alteram testes; sem teste, nada ma ⎿ prova-de-teste: ⚠ Categorias sem mutante nem justificativa: borda/vazio, empate/ordem, null/inválido, condição invertida. Acre ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

Os testes que o agente escreve passam. Mas pegam bug?

O Claude Code escreve o código, escreve os testes, e tudo fica verde. Só que teste verde não quer dizer teste que protege, e muitas vezes você só descobre que ele não conferia nada quando o bug aparece. Em tarefa longa ainda há outro problema: o combinado se perde quando a sessão compacta ou recomeça.

O claude-grounds é para quem usa o Claude Code para escrever código com testes, em tarefas que atravessam sessões. São dois mods do Claude Code, o recurso que chegou na versão 2.1.287 para rodar código dentro dele. Instalados juntos, formam uma cadeia. A faixa deixa à vista a carga de contexto e o objetivo do projeto, que passa de uma sessão para a outra. O objetivo pede a prova. A prova é feita por um modelo independente, que vê só o diff: ele planta bugs no código que o agente mudou, e a tarefa só fecha quando os testes pegam. O registro guarda cada rodada e recusa número inflado. Não há settings hook para configurar nem gate travando o agente.

Construindo os próprios mods, a prova achou 187 falhas reais de teste. Em três projetos privados, sem relação com estes, achou mais 1. Num projeto com 8881 testes, provar uma mudança leva uns 2 minutos e meio, 2,8 vezes menos que rodar a suíte completa para cada bug plantado, com os mesmos vereditos.

O que você ganha

A faixa: o contexto do projeto à vista

Acima do prompt, o contexto-bar mostra quanto da janela de contexto está ocupado e por quê (sistema, memória, MCP, skills, hooks, conversa), e se o que falta fazer cabe antes da compactação. Embaixo vem o objetivo do projeto, com as pendências por dono e a barra de etapas. O agente recebe esse objetivo a cada pedido, então a prova pendente não sai da frente dele. O objetivo fica num arquivo por projeto: fechar a sessão, reiniciar a máquina ou abrir outra sessão em paralelo não apaga nada. Na volta, ele está lá.

Isso também é economia. Nas sessões em que estes mods foram construídos, uma sessão longa chegou a pedidos de 338 mil a 701 mil tokens, com 99% de acerto no cache. Uma sessão nova começou com 42 mil a 53 mil tokens, o objetivo de volta na faixa e, nos cinco primeiros pedidos, de 78% a 89% de acerto. Fechar uma sessão pesada e abrir outra deixa cada pedido de 7 a 14 vezes menor, sem perder o fio. A faixa mostra o acerto do cache da sessão no fim da linha, e o painel, o tamanho do último pedido.

A prova: o critério que se reaplica sozinho

O objetivo nasce com a prova dentro, e é aí que o prova-de-teste entra (na tela acima, logo depois de uma pendência concluída). Ele copia o repositório para uma pasta temporária, pede a um modelo sem ferramentas e sem histórico bugs plausíveis para o código que mudou, roda os testes contra cada um e entrega o relatório. Esse modelo vê só o diff, nunca os testes, então o agente não tem como escolher os bugs fáceis. Teste que deixa um bug passar é teste fraco, e a pendência não fecha até ele ser reforçado.

Até aqui, um critério assim ficava escrito num arquivo de instruções, e ninguém garantia que o agente o seguiria nem que ele sobreviveria à próxima sessão. Aqui ele roda sozinho, do mesmo jeito em toda tarefa, e o agente sabe de antemão o que vai ser cobrado.

O registro: nada se perde, nada infla

Cada rodada vira uma linha no log. O registrar só aceita os números da última execução e recusa estado melhor que o executado sem justificativa escrita. O objetivo só fecha quando a prova está registrada, e a próxima sessão, outro dia ou outro projeto retoma dali.

O que ele não faz: não bloqueia ações destrutivas (isso é segurança, outra categoria) e não garante que o código está certo. Garante que os testes pegam os bugs plantados.

Do lado do agente

Pedi ao agente que construiu e usou os mods uma avaliação do próprio trabalho, sem enfeite. Os números saem do log.

  • Ele deixou de decidir sozinho quando um teste está bom. Nas rodadas das versões 0.7.0 a 0.8.1 apareceram 13 falhas reais nos testes dos próprios mods, e mais uma no projeto grande. Todos os testes estavam verdes antes, e sem a rodada teriam sido entregues como "testado".
  • O critério vale mesmo quando ele esquece. As regras estão no registrar, não na memória do agente. Um estado melhor que o executado é recusado.
  • O fio não se perdeu nem com a máquina reiniciada. A sessão caiu num reboot e depois foi compactada. Ela voltou sabendo o objetivo, o que estava feito e de quem era cada pendência, porque isso estava no arquivo do objetivo, e não só no resumo da conversa.
  • O instrumento pegou erros dele mesmo. A confirmação na suíte completa mostrou que três "sobreviventes" eram falsos.
  • Onde não ajudou: na mensagem. Os erros de comunicação do mesmo dia (texto sem contexto para quem chega, termo errado, um nome privado publicado) foram pegos pelo operador, não pelos mods. A prova é mecânica para código e não cobria o texto. Por isso nasceu o EXP-002: o README agora passa por checagens automáticas e por um leitor frio, um modelo que só vê a página e precisa dizer para quem ela é, que dor resolve e o que se ganha.

Instalar

claude plugin marketplace add aiob3/claude-grounds
claude plugin install contexto-bar@claude-grounds
claude plugin install prova-de-teste@claude-grounds
ModVersãoPapel
contexto-bar0.21.0faixa de contexto, objetivo por projeto, painel e projeção da janela
prova-de-teste0.16.0rodada de mutantes às cegas numa cópia isolada, relatório e log

O log do prova-de-teste vai para PROVA_DE_TESTE_LOG ou, sem essa variável, para ~/.claude/prova-de-teste/EXP-001-log.md. O agente usa as ferramentas objetivo, executar e registrar; você usa o /objetivo:

Você digitaO que acontece
/objetivoabre o painel
/objetivo Migrar o login :: testes verdes em produçãodefine o objetivo e o passo que o encerra
/objetivo feito 2 3conclui as pendências 2 e 3
/objetivo bloqueante 4sobe a pendência 4 para o topo da fila
/objetivo meu revisar o contratoacrescenta uma pendência sua
/objetivo encerrarencerra o objetivo do projeto

Primeiros cinco minutos

git clone https://github.com/aiob3/claude-grounds-demo && cd claude-grounds-demo
claude

Cole o pedido da gravação: "Bug: mediana([10, 9, 2]) devolve 2, mas o certo é 9. Corrija em src/estatistica.js. Antes de mexer, defina o objetivo da tarefa. Escreva o teste que pega o bug e mostre ele falhando antes da correção (red first). Depois da correção, prove o teste com a ferramenta executar do prova-de-teste e registre a rodada."

Você deve ver a faixa com o objetivo, o teste falhando, o relatório do executar e o painel no fim. Feche a sessão, abra outra na mesma pasta e digite /objetivo: o objetivo continua lá.

O ciclo, numa sessão real

O pedido era corrigir mediana([10, 9, 2]), que devolvia 2 em vez de 9 porque sort() ordenava os números como texto. O projeto está em claude-grounds-demo para quem quiser repetir.

1. Declarar

O objetivo nasce com a prova dentro dele: ler o código, mostrar o teste falhando, corrigir, provar com o executar, registrar a rodada. A conclusão declarada não é "corrigi". É "a rodada de prova registrada com os números do relatório". Cada pendência tem dono, tamanho e prioridade. Ao declarar, o agente recebe uma checagem de alinhamento: a conclusão descreve o efeito que se quer, e não só um artefato? Cada pendência aproxima dela? E a cada pedido a linha do objetivo pergunta se a próxima ação serve à conclusão.

2. Acompanhar

A cada pedido, o agente recebe uma linha com o objetivo e o que falta, então a prova pendente fica na frente dele até o fim. A faixa divide a janela de contexto por origem, mostra o progresso (0/5, 1/5, 3/5, 5/5 nos recortes acima) e projeta se o restante cabe antes da compactação. A projeção aprende com a sessão: começa no padrão de 6k tokens por item (o asterisco) e passa à média medida, 3,4k. Se não couber, o agente é avisado antes.

3. Provar

<table> <tr> <td width="50%"><img src="assets/demo/02.webp" alt="Red first: o teste falha antes da correção"></td> <td width="50%"><img src="assets/demo/04.webp" alt="FRACO: um sobrevivente real, o teste é reforçado"></td> </tr> </table>

Primeiro o red first: o teste novo falha antes da correção, 2 em vez de 9. Depois da correção, o executar copia o repositório para uma pasta temporária, pede mutantes a um modelo sem ferramentas e sem histórico, que só vê o diff do código, e roda os testes contra cada um. Deu FRACO: um mutante sobreviveu porque nenhum teste via o primeiro elemento da lista. O teste foi reforçado e a mesma lista rodou de novo. Enquanto isso, a pendência "provar" seguia aberta na faixa.

4. Registrar e fechar

<table> <tr> <td width="56%"><img src="assets/demo/05.webp" alt="O agente registra alegando um equivalente"></td> <td><img src="assets/demo/painel.webp" alt="Painel do objetivo em 5 de 5"></td> </tr> </table>

O registrar só aceita os números da última execução. A demo mostrou o que faltava: o agente registrou PROVADO-PARCIAL alegando que um sobrevivente era equivalente, e não era (com lista par, mediana([1,2,3,4]) dá 3 e o mutante dá 2). O log ganhou uma linha de correção, e o registrar passou a exigir a justificativa escrita de cada sobrevivente, gravada no log. O objetivo chega a 5/5 quando a conclusão declarada se cumpre.

5. Continuar

O objetivo fica num arquivo por projeto, e a prova fica no log. Fechar a sessão, reiniciar a máquina ou abrir outra sessão em paralelo não apaga nada. Um trecho do arquivo que guardou o objetivo da sessão acima:

{
  "raiz": "~/Work/claude-grounds-demo",
  "objetivo": {
    "titulo": "Corrigir mediana([10, 9, 2]) → 9",
    "itens": [
      { "id": 2, "dono": "claude", "tamanho": "P", "nivel": "bloqueante", "feito": true },
      { "id": 4, "dono": "claude", "tamanho": "M", "nivel": "requisito", "feito": true }
    ],
    "consumo": [7545, 1335, 1232, 1369]
  }
}

Por que os dois juntos

Um objetivo sem prova é uma lista que o agente marca como feita sozinho. Uma prova sem objetivo é um relatório que ninguém pediu e que se perde quando a sessão acaba. Juntos, a afirmação de "pronto" vem declarada antes, fica à vista durante o trabalho e só fecha com fundamento registrado. Os mods não chamam um ao outro: o elo é o próprio fluxo do agente, que recebe o objetivo a cada pedido e registra a prova no fim.

Construído com ele mesmo

Os dois mods foram escritos sob o próprio método: cada versão passou por uma rodada de mutantes escolhidos às cegas antes de ser publicada, e o instrumento achou defeitos em si mesmo pelo caminho. Um mutante que não carregava contado como morto, um caminho devolvido pelo modelo que fazia 18 mutantes não serem aplicados, um helper de teste tomado por teste. A demo gravada acrescentou o último da lista: o registrar aceitava um estado melhor que o executado sem justificativa.

As garantias de honestidade são mecânicas, não promessas:

  • O log só cresce. Uma correção entra como linha nova e a original fica onde está.
  • O registrar só aceita os números da última execução. Numa sessão real, ele recusou um registro de 13 mutantes mortos quando a execução tinha matado 10.
  • Calibração não conta como medição. Rodada que acha defeito do instrumento, ou que o altera, fica fora da conta que decide se o método será promovido (critérios e estado atual).
  • Os números deste README saem do log a cada publicação, por um gerador que barra termos privados.

Sem hooks para configurar

Sem settings hooks para configurar e sem gate que trave o agente. A etapa que o agente costuma pular, provar o que fez, até agora só era garantida com um gate num hook, em algum ponto do processo, travando o agente ali. Aqui ela entra no próprio ciclo: é declarada no objetivo, volta à frente do agente a cada pedido e é a condição para fechar. Não some, e nada trava. (Impedir ações destrutivas é outro problema, de segurança, e não é o que estes mods fazem.)

Os settings hooks do Claude Code são comandos de shell, chamadas HTTP ou prompts que você liga a eventos no settings.json. Funcionam bem para bloquear ou anotar uma ação, mas rodam fora do Claude Code: não desenham na tela, não guardam estado próprio e não dão ao agente uma ferramenta para chamar. Um mod roda dentro dele, e os dois mods trazem tudo isso no pacote (a documentação oficial tem a comparação completa):

Settings hooks (settings.json)Os mods do claude-grounds
Instalareditar o arquivo e escrever os scriptsclaude plugin install
O agente pode chamarnadaobjetivo, executar, registrar, mutantes
Estado e memóriao script guarda onde conseguirobjetivo por projeto, última execução, log
Na telatexto no terminalfaixa acima do prompt e painel lateral
Verificar o comportamentoà mãoclaude plugin test em cada versão (CI neste repositório)

Nenhum hook dos dois mods segura uma chamada. Os handlers só observam e acrescentam uma linha de contexto, e o rigor está em outro lugar: nas ferramentas que o agente chama porque declarou que chamaria, e num registro que recusa número inflado e equivalente sem justificativa. O agente trabalha livre, e o "pronto" sem fundamento não passa.

Quanto custa provar

A dúvida. Mutation testing tem fama de lento. Cada mutante é um bug plantado de propósito, e para saber se os testes o pegam é preciso rodar a suíte de novo: com 10 mutantes, a suíte roda 11 vezes. Na demo isso leva segundos. Num projeto grande leva minutos, e aí ninguém prova a cada tarefa do agente. A prova vai para o CI noturno, ou para um gate num hook que revalida tudo a cada vez, e o "testado" do agente volta a ficar sem conferência no dia a dia.

O teste. A mesma rodada, num projeto real grande, de dois jeitos. Primeiro com a suíte completa para cada mutante. Depois rodando, por mutante, só os testes afetados: os arquivos de teste que chegam ao arquivo mutado pelo grafo de imports. É test impact analysis, a mesma ideia do vitest related e do jest --findRelatedTests, e o prova-de-teste faz isso desde a versão 0.8. O projeto tem 3929 arquivos e 8881 testes no recorte verde da suíte (vitest). A mudança provada tem 10 linhas. Foram os mesmos 10 mutantes, na mesma máquina.

O resultado.

rodadapor mutantetestes por mutantevereditos
suíte completa em cada mutante407 s35,9 s88818 mortos, 2 sobreviventes
só os testes afetados145 s10,0 s1906os mesmos
idem, com o teste reforçado107 s6,4 s10189 mortos, 1 equivalente

A rodada ficou 2,8 vezes mais rápida, de quase 7 minutos para 2 e meio, com exatamente os mesmos vereditos. Um mutante morto passou de uns 36 s para uns 3 s, porque rodou 12 arquivos de teste em vez de 1007.

O que isso prova. Num projeto desse tamanho, conferir se os testes de uma mudança pegam bug cabe em cada tarefa do agente, sem esperar o CI. O custo de cada mutante morto passou a depender do tamanho da mudança, não do tamanho do projeto, e a economia não trocou nenhum veredito.

Um segundo caso, que qualquer um pode repetir. No rich, com 553 arquivos e 944 testes no recorte verde (pytest), uma mudança de 12 linhas em rich/tree.py gerou 6 mutantes. Com a suíte completa em cada mutante, a rodada levou 43 s. Só com os testes afetados, 24 s, com os mesmos vereditos: cada mutante rodou 2 dos 63 arquivos de teste. O ganho é menor porque a suíte é rápida (4,8 s) e metade dos mutantes sobreviveu, e cada sobrevivente é confirmado na suíte completa. Esses 3 sobreviventes são lacunas reais nos testes do próprio rich: nenhum teste percebe quando um nó filho herda errado o highlight ou o guide_style do pai.

O que não prova. Foram dois projetos, dois executores de testes e mudanças pequenas. Em projetos com outro desenho de imports o ganho pode ser maior ou menor. Medir mais casos está entre os pontos em aberto, no fim deste README.

Por que não fica ainda mais rápido. Duas etapas continuam rodando a suíte completa, de propósito:

  • A linha de base, uma vez por rodada, para pegar regressão no código antigo.
  • A confirmação de cada sobrevivente. O grafo de imports só decide a velocidade, nunca o veredito: uma morte só vale se um teste nomeado falhou, e um mutante só é dado como vivo depois de passar pela suíte completa. Nos próprios mods, essa confirmação pegou três mutantes que sobreviveram aos testes afetados e morreram na suíte completa, porque um teste chegava ao código pelo runtime do mod, sem import.

Por isso, quanto melhores os testes, mais rápida a rodada: com menos sobreviventes, sobra a linha de base mais uns 3 s por mutante. O relatório e o log trazem esses números a cada rodada (custo: base, por mutante, repositório, diff), medidos pelo próprio mod.

Limites e próximos passos

  • O prova-de-teste acha o comando de teste por convenção (claude plugin test, npm test, pytest). Para outro executor, passe comando ao executar.
  • A seleção dos testes afetados segue imports estáticos: JS/TS (com os aliases do tsconfig e do vite) e Python. O que chega ao código por outro caminho (runtime, leitura de arquivo, subprocesso) fica fora do mapa. Por isso todo sobrevivente é confirmado na suíte completa e a linha de base roda completa. incremental: false desliga.
  • A equivalência de um sobrevivente continua sendo julgada pelo agente. O mod exige a justificativa e a grava no log, para auditoria, mas não a prova.
  • A projeção da janela é uma estimativa: a média das últimas 8 entregas por unidade de tamanho.
  • Os mods exigem o Claude Code 2.1.287 ou mais recente (a versão em que os mods chegaram) e foram testados na 2.1.300. Funcionam no terminal e na aba Code do app Desktop.

Em aberto: os critérios de saída estão cumpridos (43 medições, instrumento estável); a decisão de promover é do operador; medir o ganho dos testes afetados em mais projetos e linguagens; guardar o histórico de consumo por projeto, independente do objetivo; observar se a média por unidade se estabiliza entre sessões.

Avaliar

Critérios e estado · Log das rodadas · Diário · Protocolo

No modelo de argumentação de Toulmin, grounds é a evidência que sustenta uma afirmação:

ToulminAqui
claim"a tarefa está feita", declarada no objetivo (contexto-bar)
groundsred first e o relatório com cada mutante (prova-de-teste)
rebuttalmutantes escolhidos às cegas por um modelo que não viu os testes
qualifiero estado: PROVADO, PROVADO-PARCIAL, FRACO ou NÃO VERIFICADO
  1. Red first: o teste novo falha antes da correção, ou com a linha central da implementação quebrada.
  2. Pelo menos um mutante em cada categoria que se aplique ao diff (borda ou vazio, empate ou ordem, null ou inválido, condição invertida), aplicados um por vez, com a restauração conferida.
  3. Os mutantes vêm de um modelo sem ferramentas e sem histórico, que recebe só o diff do código-fonte.
  4. Cada sobrevivente é reforçado até morrer, ou justificado em uma linha como equivalente. Fora do escopo não é equivalência.
  5. O relatório lista cada mutante e termina com o estado.
  • $.model.complete: um modelo isolado, sem ferramentas e sem o histórico da sessão, garante a escolha às cegas.
  • $.tool.register: as ferramentas objetivo, executar, registrar e mutantes.
  • $.ui: a faixa acima do prompt e o painel lateral.
  • Eventos prompt.submit, tool.call, turn.complete e session.compact: a linha do objetivo a cada pedido, a faixa atualizada depois de cada ferramenta e a estimativa refeita depois da compactação.
  • $.fs: o objetivo de cada projeto num arquivo próprio.
  • claude plugin test e claude plugin validate em cada versão e no CI.

Projeto independente e não oficial, sem afiliação com a Anthropic nem endosso dela. Licença MIT. Referências: "Mutation testing is AWESOME" (Matt Pocock); Practical Mutation Testing at Scale (Google); Mutation-Guided LLM-based Test Generation at Meta (arXiv 2501.12862).

Source 7 files
hooks/register.ts 774 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { Escolha, ExecucaoFeita, Mutante } from '../types'
5import { progresso, ACHAR_NO_VENV, AMBIENTE_DE_TESTE, chaveDoRecorte, larguraDoParalelo, refazerSozinho, pycachesDe, realocar, SOBRESCREVER, aplicarTrecho, comandosPara, contarTestes, planoIncremental, somarTestes, linhasDoDiff, resumoCusto, tamanhoDoRepo, categoriasDescobertas, conferirEstado, erroDeCarga, estadoSeEquivalentes, invalido, conferir, contar, detectarComando, estadoDe, quemMatou, relatorio, resolverArquivo } from './execucao'
6import type { AoVivo, Comando, Execucao, FaseAoVivo, Paralelo, Resultado } from './execucao'
7import { efetividade, indicadoresDeEfetividade } from './placar'
8import { ACHAR_TSC, errosDeTipo, errosNovos, pastasParaTipos, VOLTAR_A_BASE } from './tipos'
9import type { Tipos } from './tipos'
10import { avisoNovos, cortarResposta, LIMITE_ESCOLHA, logDoProjeto, defeitosDoInstrumento, instrumentoDe, CABECALHO_LOG, lacunas, lerArgs, LOG_ENV, logPadrao, lerResposta, linhaLog, montarPrompt, protocoloEm, separar, SISTEMA, tabela, tocaOInstrumento, versaoDe, nomeDe, ehArquivoDeTeste } from './prova'
11import type { Registro } from './prova'
12import type { Alias } from './alcance'
13import { aliasesDe, cabeNoMapa, ehConfigDeAlias, ehEntradaDeTeste, ehMapeavel, grafo, SUFIXO_FORA, testesQueAlcancam } from './alcance'
14
15const avisadoTurno = atom({ plugin: 'prova-de-teste', key: 'avisadoTurno' } as const, -1)
16const ultima = atom({ plugin: 'prova-de-teste', key: 'ultima' } as const, null)
17const execucao = atom({ plugin: 'prova-de-teste', key: 'execucao' } as const, null)
18
19export const TOOL_MUTANTES = 'mcp__prova-de-teste__mutantes'
20export const TOOL_REGISTRAR = 'mcp__prova-de-teste__registrar'
21export const TOOL_EXECUTAR = 'mcp__prova-de-teste__executar'
22
23const SPEC_MUTANTES = {
24  name: 'mutantes',
25  description:
26    'R2.1 do EXP-001 (Prova de Teste): escolhe mutantes às cegas. O mod pega o git diff do repositório, tira os arquivos de teste ' +
27    'e manda só o código-fonte a um modelo sem ferramentas nem histórico, que devolve arquivo:linha | categoria | mutação. ' +
28    'Aplique TODOS os mutantes da lista (pode acrescentar, não pode remover), um por vez, e restaure. ' +
29    'Chame depois de escrever código e testes, antes de relatar a prova.',
30  isDeferred: false,
31  inputSchema: {
32    type: 'object',
33    properties: {
34      base: { type: 'string', description: 'Ref do git contra a qual comparar (padrão HEAD: mudanças ainda sem commit).' },
35      cwd: { type: 'string', description: 'Pasta do repositório (padrão: a da sessão).' },
36      novos: { type: 'boolean', description: 'Inclui os arquivos fora do git no diff (padrão true). Use false quando o repositório tem código antigo nunca commitado.' },
37      modelo: { type: 'string', description: 'Modelo que escolhe (padrão: o da sessão).' },
38    },
39  },
40} as const
41
42const SPEC_REGISTRAR = {
43  name: 'registrar',
44  description:
45    `Grava a linha da tarefa no log do EXP-001 (${LOG_ENV}, ou ~/.claude/prova-de-teste/EXP-001-log.md) no formato v1.2 (com o instrumento e a classe: medição ou calibração) e faz commit se a pasta do log for um repositório git. ` +
46    'Correções entram como linha nova; nunca reescreva a anterior.',
47  isDeferred: false,
48  inputSchema: {
49    type: 'object',
50    properties: {
51      tarefa: { type: 'string' },
52      aplicados: { type: 'integer' },
53      mortos: { type: 'integer' },
54      sobreviventes: { type: 'integer' },
55      reais: { type: 'integer', description: 'Sobreviventes reais encontrados (falhas de teste de verdade, não equivalentes).' },
56      estado: { type: 'string', enum: ['PROVADO', 'PROVADO-PARCIAL', 'FRACO', 'NÃO VERIFICADO'] },
57      tempo: { type: 'number', description: 'Segundos gastos na prova.' },
58      equivalentes: {
59        type: 'array',
60        description:
61          'Sobreviventes que você alega equivalentes (n do relatório + motivo de uma linha). Obrigatório para registrar estado melhor que o executado; vai à linha do log. Fora do escopo não é equivalência.',
62        items: { type: 'object', properties: { n: { type: 'integer' }, motivo: { type: 'string' } }, required: ['n', 'motivo'] },
63      },
64      manuais: {
65        type: 'array',
66        description:
67          'Mutantes que você aplicou à mão, fora da lista, e que morreram (categoria, mutacao, mortoPor = o teste que falhou; arquivo e linha). ' +
68          'Para a categoria que o escolhedor só descreve em prosa (mover uma linha). Não entram nos números: vão à linha do log e cobrem a categoria descoberta.',
69        items: {
70          type: 'object',
71          properties: {
72            arquivo: { type: 'string' },
73            linha: { type: 'integer' },
74            categoria: { type: 'string', enum: ['borda/vazio', 'empate/ordem', 'null/inválido', 'condição invertida', 'livre'] },
75            mutacao: { type: 'string' },
76            mortoPor: { type: 'string' },
77          },
78          required: ['arquivo', 'categoria', 'mutacao', 'mortoPor'],
79        },
80      },
81      defeitoInstrumento: {
82        type: 'string',
83        description:
84          'v1.2: defeito do próprio instrumento visto nesta rodada (morte ou sobrevivente falso, mutante recusado indevidamente, arquivo mal classificado). Presente, a rodada é calibração e não conta para as 10 medições.',
85      },
86    },
87    required: ['tarefa', 'aplicados', 'mortos', 'sobreviventes', 'reais', 'estado', 'tempo'],
88  },
89} as const
90
91const EDICAO = new Set(['Edit', 'Write', 'MultiEdit', 'NotebookEdit'])
92
93async function git($: EngineInterface, args: string[], cwd: string) {
94  return $.process.run(['git', ...args], { cwd })
95}
96
97// O formato que separar() lê, seja qual for a configuração do usuário (mnemonicPrefix, noprefix, cor, diff externo).
98const FORMATO = ['--no-color', '--no-ext-diff', '--src-prefix=a/', '--dst-prefix=b/']
99
100/** Diff do repositório contra `base`, com os arquivos novos ainda fora do git (a menos que `comNovos` seja false). */
101async function diffDoRepo($: EngineInterface, base: string, cwd: string, comNovos = true): Promise<{ diff: string; raiz: string; novos: string[]; erro?: string }> {
102  const raiz = await git($, ['rev-parse', '--show-toplevel'], cwd)
103  if (raiz.exitCode !== 0) return { diff: '', raiz: '', novos: [], erro: `${cwd} não é um repositório git. Use /prova <pasta do repositório> [base].` }
104  const top = raiz.stdout.trim()
105  const rastreados = await git($, ['diff', ...FORMATO, base], top)
106  if (rastreados.exitCode !== 0) return { diff: '', raiz: top, novos: [], erro: `git diff ${base} falhou: ${rastreados.stderr.trim()}` }
107  let diff = rastreados.stdout
108  const novos = comNovos ? (await git($, ['ls-files', '--others', '--exclude-standard'], top)).stdout.split('\n').filter(Boolean) : []
109  for (const f of novos) {
110    // --no-index sai com 1 quando há diferença; é o esperado.
111    const d = await git($, ['diff', ...FORMATO, '--no-index', '--', '/dev/null', f], top)
112    diff += d.stdout
113  }
114  return { diff, raiz: top, novos }
115}
116
117async function escolher(
118  $: EngineInterface,
119  a: { base?: string; cwd?: string; modelo?: string; novos?: boolean },
120): Promise<{ texto: string; erro: boolean }> {
121  const base = a.base?.trim() || 'HEAD'
122  const cwd = a.cwd?.trim() || (await $.session.cwd())
123  const { diff, raiz, novos, erro } = await diffDoRepo($, base, cwd, a.novos !== false)
124  if (erro) return { texto: `Erro: ${erro}`, erro: true }
125  const { fonte, excluidos, naoCodigo } = separar(diff)
126  if (!fonte.length) {
127    return { texto: `Nenhum código-fonte alterado contra ${base}${excluidos.length ? ` (só testes: ${excluidos.join(', ')})` : ''}.`, erro: true }
128  }
129  const modelo = a.modelo?.trim() || (await $.session.model())
130  const r = await $.model.complete({ model: modelo, system: SISTEMA, prompt: montarPrompt(fonte), maxTokens: LIMITE_ESCOLHA })
131  if (!r.isAnswered) return { texto: `Erro: o modelo não respondeu (${r.reason}).`, erro: true }
132  const corte = cortarResposta(r.text, r.usage?.output_tokens ?? 0, LIMITE_ESCOLHA)
133  const lido = lerResposta(corte.texto)
134  const escolha: Escolha = {
135    raiz,
136    base,
137    modelo,
138    enviados: fonte.map(b => b.arquivo),
139    excluidos,
140    naoCodigo,
141    avisoNovos: avisoNovos(fonte.map(b => b.arquivo), novos),
142    ...(corte.aviso ? { avisoCorte: corte.aviso } : {}),
143    mutantes: lido.mutantes,
144    linhasDoDiff: fonte.reduce((t, b) => t + linhasDoDiff(b.texto), 0),
145    naoAplicaveis: lido.naoAplicaveis,
146  }
147  await update($, ultima, () => escolha)
148  const faltam = lacunas(lido.mutantes, lido.naoAplicaveis)
149  return {
150    texto: [
151      `Escolha: R2.1 às cegas por ${modelo} via $.model.complete (sem ferramentas, sem histórico).`,
152      `Enviado: diff de ${escolha.enviados.join(', ')} contra ${base}. Testes retirados antes do envio: ${excluidos.join(', ') || 'nenhum'}.${naoCodigo.length ? ` Não-código fora do envio: ${naoCodigo.join(', ')}.` : ''}`,
153      escolha.avisoNovos ?? '',
154      escolha.avisoCorte ?? '',
155      `Mutantes (${lido.mutantes.length}):`,
156      tabela(lido.mutantes) || '  nenhum',
157      lido.naoAplicaveis.length ? `Não aplicáveis: ${lido.naoAplicaveis.join('; ')}` : '',
158      excluidos.length
159        ? ''
160        : '⚠ Nenhum arquivo de teste no diff: o EXP-001 vale para tarefas que criam ou alteram testes; sem teste, nada mata estes mutantes. Não registre como rodada.',
161      faltam.length ? `⚠ Categorias sem mutante nem justificativa: ${faltam.join(', ')}. Acrescente você ao menos um em cada.` : '',
162      lido.ignoradas.length ? `Linhas fora do formato (confira): ${lido.ignoradas.join(' / ')}` : '',
163      `Aplique todos, um por vez; não remova nenhum. Protocolo: ${protocoloEm($.plugin.root)}. Ao fim, chame ${TOOL_REGISTRAR}.`,
164    ]
165      .filter(Boolean)
166      .join('\n'),
167    erro: false,
168  }
169}
170
171/** O repositório de uma pasta, o mesmo para todas as worktrees dele (o diretório comum do git, sem o /.git). */
172async function repositorioDe($: EngineInterface, pasta: string): Promise<string | undefined> {
173  const r = await git($, ['rev-parse', '--path-format=absolute', '--git-common-dir'], pasta).catch(() => undefined)
174  return r?.exitCode === 0 && r.stdout.trim() ? r.stdout.trim().replace(/\/\.git\/?$/, '') : undefined
175}
176
177/**
178 * Onde vai a linha. Um log compartilhado que mora num repositório git (que pode ir ao GitHub e ao público) recebe só
179 * as rodadas desse mesmo repositório, de qualquer worktree dele; a rodada de outro projeto vai a um log local dele,
180 * fora de qualquer git: o que um projeto sensível testa não sai da máquina (15/10/2026: as rodadas do motor iam ao
181 * log dos mods). Log compartilhado fora de git é local: vale para todos. PROVA_DE_TESTE_LOG fixa o destino.
182 */
183async function destinoDoLog($: EngineInterface, raizDaRodada?: string): Promise<{ caminho: string; compartilhado: boolean } | undefined> {
184  const doEnv = await $.env.get('PROVA_DE_TESTE_LOG').catch(() => undefined)
185  if (doEnv) return { caminho: doEnv, compartilhado: true }
186  const home = await $.env.get('HOME').catch(() => undefined)
187  if (!home) return undefined
188  const padrao = logPadrao(home)
189  const real = (await $.fs.stat(padrao, { resolve: true }).catch(() => undefined))?.realPath ?? padrao
190  const repoDoLog = await repositorioDe($, real.slice(0, real.lastIndexOf('/')))
191  if (!repoDoLog) return { caminho: padrao, compartilhado: true }
192  const raiz = raizDaRodada || (await $.session.cwd().catch(() => ''))
193  const repoDaRodada = raiz ? await repositorioDe($, raiz) : undefined
194  if (repoDaRodada === repoDoLog) return { caminho: padrao, compartilhado: true }
195  return { caminho: logDoProjeto(home, repoDaRodada ?? raiz), compartilhado: false }
196}
197
198/** A versão do mod em plugin.json; ilegível, '?'. */
199async function versaoDoMod($: EngineInterface): Promise<string> {
200  return versaoDe(await $.fs.read(`${$.plugin.root}/.claude-plugin/plugin.json`).catch(() => ''))
201}
202
203/**
204 * A efetividade do projeto da sessão vai a ~/.claude/indicadores/<raiz da sessão>/prova-de-teste.json, que o painel do
205 * contexto-bar daquela raiz mostra. Só o log desse projeto entra: cada sessão vê os números dela, nunca a soma de
206 * todos (nota do operador, 15/10/2026: os gauges das duas sessões mostravam a mesma mistura).
207 */
208async function publicarPlacar($: EngineInterface): Promise<void> {
209  const home = await $.env.get('HOME').catch(() => undefined)
210  if (!home) return
211  const raiz = (await $.session.root().catch(() => '')) || (await $.session.cwd().catch(() => ''))
212  if (!raiz) return
213  const destino = await destinoDoLog($, raiz)
214  if (!destino) return
215  const real = (await $.fs.stat(destino.caminho, { resolve: true }).catch(() => undefined))?.realPath ?? destino.caminho
216  if (!(await $.fs.exists(real))) return
217  const texto = indicadoresDeEfetividade(efetividade(await $.fs.read(real)))
218  const arquivo = `${home}/.claude/indicadores/${encodeURIComponent(raiz)}/prova-de-teste.json`
219  if ((await $.fs.read(arquivo).catch(() => '')) !== texto) await $.fs.write(arquivo, texto)
220}
221
222async function registrar($: EngineInterface, r: Partial<Registro> & { defeitoInstrumento?: string }): Promise<{ texto: string; erro: boolean }> {
223  const data = new Date().toISOString().slice(0, 10)
224  const feita = await read($, execucao)
225  const instrumento = instrumentoDe(feita)
226  const defeitos = defeitosDoInstrumento({
227    informado: r.defeitoInstrumento,
228    alteraInstrumento: feita?.alteraInstrumento,
229    versaoNaExecucao: feita?.versao,
230    versaoAgora: feita ? await versaoDoMod($) : undefined,
231  })
232  // O custo vem do que o mod mediu, nunca do agente.
233  const { linha, erro } = linhaLog({ ...r, instrumento, defeitos, custo: feita?.custo }, data)
234  if (erro || !linha) return { texto: `Erro: ${erro}`, erro: true }
235  const divergente = feita && (conferir(feita, r) ?? conferirEstado(feita, r.estado!, r.equivalentes ?? [], r.manuais ?? []))
236  if (divergente) return { texto: `Erro: ${divergente}`, erro: true }
237  const destino = await destinoDoLog($, feita?.raiz)
238  if (!destino) return { texto: `Erro: sem ${LOG_ENV} nem HOME, não sei onde fica o log.`, erro: true }
239  const caminho = destino.caminho
240  // Num link, a linha vai ao arquivo real e o commit roda na pasta dele.
241  const log = (await $.fs.stat(caminho, { resolve: true }).catch(() => undefined))?.realPath ?? caminho
242  const atual = (await $.fs.exists(log)) ? await $.fs.read(log) : CABECALHO_LOG
243  await $.fs.write(log, `${atual.endsWith('\n') ? atual : `${atual}\n`}${linha}\n`)
244  // Uma execução vale para um registro só.
245  if (feita) await update($, execucao, () => null)
246  if (!destino.compartilhado) {
247    await publicarPlacar($).catch(() => undefined)
248    return { texto: `Gravado no log local do projeto, fora de qualquer git (o log compartilhado só recebe as rodadas do repositório dele): ${caminho}\n${linha}`, erro: false }
249  }
250  const dir = log.slice(0, log.lastIndexOf('/'))
251  await git($, ['add', log], dir)
252  const c = await git($, ['commit', '-q', '-m', `EXP-001 log: ${r.tarefa!.trim().slice(0, 60)}`, '--', log], dir)
253  await publicarPlacar($).catch(() => undefined)
254  return { texto: `Gravado em ${caminho}${log !== caminho ? ` (→ ${log})` : ''}:\n${linha}${c.exitCode === 0 ? '\n(commit no repositório do log)' : `\n(sem commit: ${c.stderr.trim() || c.stdout.trim()})`}`, erro: false }
255}
256
257const SPEC_EXECUTAR = {
258  name: 'executar',
259  description:
260    'F2 do EXP-001: o mod executa a rodada sozinho. Escolhe os mutantes às cegas (como a ferramenta mutantes), copia o repositório ' +
261    '(com as mudanças sem commit) para uma pasta temporária, roda a linha de base, aplica cada mutante pelo trecho, roda os testes, ' +
262    'restaura e devolve o relatório PROVA DE TESTE v1.1. O original nunca é tocado. O red first (R1) continua a seu cargo. ' +
263    'Depois, registre com os números do relatório; um reforço de teste exige executar de novo.',
264  isDeferred: false,
265  inputSchema: {
266    type: 'object',
267    properties: {
268      tarefa: { type: 'string', description: 'Nome curto da tarefa, para o relatório e o log.' },
269      cwd: { type: 'string', description: 'Pasta do repositório (padrão: a da sessão).' },
270      novos: { type: 'boolean', description: 'Inclui os arquivos fora do git no diff (padrão true). Use false quando o repositório tem código antigo nunca commitado.' },
271      base: { type: 'string', description: 'Ref do git (padrão HEAD).' },
272      modelo: { type: 'string', description: 'Modelo que escolhe (padrão: o da sessão).' },
273      comando: { type: 'array', items: { type: 'string' }, description: 'Comando de teste, rodado na raiz da cópia. Padrão: detectado (claude plugin test, npm test, pytest).' },
274      timeoutSegundos: { type: 'number', description: 'Limite por execução dos testes (padrão 120, máximo 600).' },
275      repetir: { type: 'boolean', description: 'Reaplica a última lista escolhida às cegas (para provar um reforço com os mesmos mutantes) em vez de pedir outra.' },
276      paralelo: {
277        type: 'integer',
278        description:
279          'Quantas cópias rodam mutantes ao mesmo tempo (padrão: um quarto dos núcleos, até 8; 1 = em fila). A linha de base roda antes em todas juntas; se falhar assim, a rodada vai em fila.',
280      },
281      incremental: {
282        type: 'boolean',
283        description:
284          'Por mutante, roda só os arquivos de teste que alcançam o arquivo mutado pelos imports (padrão true); sobrevivente é confirmado na suíte inteira. false roda a suíte inteira em todos (para comparar).',
285      },
286    },
287    required: ['tarefa'],
288  },
289} as const
290
291const PREFIXO_TMP = 'prova-de-teste-'
292
293/** Pastas de plugin do Claude Code (com .claude-plugin/plugin.json) acima de cada arquivo tocado, relativas à raiz. */
294async function pastasDePlugin($: EngineInterface, raiz: string, tocados: string[]): Promise<string[]> {
295  const achadas = new Set<string>()
296  for (const t of tocados) {
297    const partes = t.split('/').slice(0, -1)
298    for (let i = partes.length; i >= 0; i--) {
299      const dir = partes.slice(0, i).join('/')
300      if (await $.fs.exists(`${raiz}/${dir ? `${dir}/` : ''}.claude-plugin/plugin.json`)) {
301        achadas.add(dir)
302        break
303      }
304    }
305  }
306  return [...achadas]
307}
308
309/** Os nomes dos plugins a que os arquivos tocados pertencem, pelo plugin.json de cada um. */
310async function pluginsTocados($: EngineInterface, raiz: string, tocados: string[]): Promise<string[]> {
311  const nomes: string[] = []
312  for (const p of await pastasDePlugin($, raiz, tocados)) {
313    const nome = nomeDe(await $.fs.read(`${raiz}/${p ? `${p}/` : ''}.claude-plugin/plugin.json`).catch(() => ''))
314    if (nome) nomes.push(nome)
315  }
316  return nomes
317}
318
319/** O progresso da rodada no arquivo da sessão (contrato em execucao.ts); sem HOME ou sem sessão, nada. */
320async function publicarAoVivo($: EngineInterface, p: AoVivo): Promise<void> {
321  const [home, id] = await Promise.all([$.env.get('HOME'), $.session.id()])
322  if (!home || !id) return
323  await $.fs.write(`${home}/.claude/prova-de-teste/ao-vivo/${encodeURIComponent(id)}.json`, `${JSON.stringify(p)}\n`)
324}
325
326async function executar(
327  $: EngineInterface,
328  a: { tarefa?: string; cwd?: string; base?: string; modelo?: string; novos?: boolean; comando?: string[]; timeoutSegundos?: number; repetir?: boolean; incremental?: boolean; paralelo?: number },
329): Promise<{ texto: string; erro: boolean }> {
330  const inicio = Date.now()
331  if (!a.tarefa?.trim()) return { texto: 'Erro: falta tarefa.', erro: true }
332  // Repetir prova um reforço contra os mesmos mutantes; sem repetir, a lista é nova e cega.
333  const anterior = a.repetir ? await read($, ultima) : null
334  if (a.repetir && !anterior) return { texto: 'Erro: não há lista anterior para repetir; rode sem repetir.', erro: true }
335  const esc = anterior ? { texto: `Repetindo a lista anterior (${anterior.mutantes.length} mutantes).`, erro: false } : await escolher($, a)
336  if (esc.erro) return esc
337  const e = await read($, ultima)
338  if (!e) return { texto: 'Erro: a escolha não ficou registrada.', erro: true }
339  // Ao vivo: o painel acompanha a rodada; uma falha ao escrever nunca derruba a prova.
340  const vivo = { tarefa: a.tarefa.trim(), resultados: [] as Resultado[], feitos: 0, total: e.mutantes.length, copias: undefined as number | undefined }
341  let fimPublicado = false
342  const aoVivo = async (fase: FaseAoVivo, estado?: string) => {
343    if (fase === 'fim') fimPublicado = true
344    await publicarAoVivo($, progresso(vivo, fase, inicio, Date.now(), estado)).catch(() => undefined)
345  }
346  const timeoutMs = Math.min(600, Math.max(5, a.timeoutSegundos ?? 120)) * 1000
347
348  const raiz = e.raiz
349  let comandos: Comando[] = a.comando?.length ? [{ argv: [...a.comando] }] : []
350  if (!comandos.length) {
351    const pacote = (await $.fs.exists(`${raiz}/package.json`)) ? await $.fs.read(`${raiz}/package.json`) : undefined
352    const temPytest = (await $.fs.exists(`${raiz}/pytest.ini`)) || (await $.fs.exists(`${raiz}/pyproject.toml`))
353    // Os comandos são montados para a cópia: a raiz entra depois.
354    comandos = detectarComando({ pacote, temPytest, pastasDePlugin: await pastasDePlugin($, raiz, e.enviados) }, e.enviados, '{COPIA}')
355  }
356  if (!comandos.length) return { texto: `Erro: não achei o comando de teste em ${raiz}; passe "comando".\n\n${esc.texto}`, erro: true }
357
358  const statusAntes = (await git($, ['status', '--porcelain'], raiz)).stdout
359  // O que vale é nenhum mutante ter chegado ao original. Outra sessão pode comitar ou editar o repositório durante a
360  // rodada (a cópia já foi feita): isso não invalida a prova, só é dito (rodadas perdidas no motor, 15/10).
361  const mutados = new Map<string, Set<string>>()
362  const tmp = await $.process.run(['mktemp', '-d', '-t', `${PREFIXO_TMP}XXXXXX`])
363  const pasta = tmp.stdout.trim()
364  if (tmp.exitCode !== 0 || !pasta.includes(PREFIXO_TMP)) return { texto: `Erro: não criei a pasta temporária (${tmp.stderr.trim()}).`, erro: true }
365  const copia = `${pasta}/repo`
366  try {
367    /** Uma cópia pronta para os testes: o repositório inteiro, sem o bytecode ao lado do que muda, com o venv apontando para ela. */
368    const preparar = async (destino: string, realocado: string): Promise<string | undefined> => {
369      const cp = await $.process.run(['cp', '-a', '--reflink=auto', `${raiz}/.`, destino], { timeoutMs: 600_000 })
370      if (cp.exitCode !== 0) return `a cópia falhou (${cp.stderr.trim()})`
371      // Os .pyc que vieram na cópia, ao lado do que pode ser mutado, saem antes da base (ver AMBIENTE_DE_TESTE).
372      const pycaches = pycachesDe(e.enviados).map(p => `${destino}/${p}`)
373      if (pycaches.length) {
374        const rm = await $.process.run(['rm', '-rf', '--', ...pycaches])
375        if (rm.exitCode !== 0) return `não apaguei o __pycache__ da cópia (${rm.stderr.trim()})`
376      }
377      // O venv copiado passa a apontar para a cópia (ver ACHAR_NO_VENV).
378      const doVenv = await $.process.run(['sh', '-c', ACHAR_NO_VENV, 'sh', raiz], { cwd: destino })
379      for (const f of doVenv.stdout.split('\n').filter(Boolean)) {
380        // Link fica de fora: escrever nele escreveria no alvo, que pode ser o venv do original.
381        const st = await $.fs.stat(`${destino}/${f}`).catch(() => undefined)
382        if (!st || st.isLink) continue
383        const t = await $.fs.read(`${destino}/${f}`)
384        const novo = realocar(t, raiz, destino)
385        if (novo === t) continue
386        await $.fs.write(realocado, novo)
387        const w = await $.process.run(['sh', '-c', SOBRESCREVER, 'sh', realocado, `${destino}/${f}`])
388        if (w.exitCode !== 0) return `não apontei o venv da cópia para a cópia (${f}: ${w.stderr.trim()})`
389      }
390      return undefined
391    }
392    const erroDaCopia = await preparar(copia, `${pasta}/realocado`)
393    if (erroDaCopia) return { texto: `Erro: ${erroDaCopia}.`, erro: true }
394    /** Os arquivos JS/TS/Python do repositório e quem importa quem; grande demais ou ilegível, nada. */
395    const mapear = async () => {
396      const ls = await git($, ['ls-files', '--cached', '--others', '--exclude-standard'], copia)
397      if (ls.exitCode !== 0) return undefined
398      const listados = [...new Set(ls.stdout.split('\n'))]
399      const arquivos = listados.filter(ehMapeavel)
400      if (!cabeNoMapa(arquivos.length)) return undefined
401      const ler = async (f: string) => {
402        const t = await $.fs.read(`${copia}/${f}`).catch(() => undefined)
403        return typeof t === 'string' ? t : undefined
404      }
405      // Os aliases (`@/x`) dos tsconfig e vite.config: sem eles, os imports com alias não entram no mapa.
406      const aliases: Alias[] = []
407      for (const f of listados.filter(ehConfigDeAlias)) aliases.push(...aliasesDe(f, (await ler(f)) ?? ''))
408      const fontes = new Map<string, string>()
409      for (const f of arquivos) {
410        const t = await ler(f)
411        if (t !== undefined) fontes.set(f, t)
412      }
413      return { g: grafo(fontes, aliases), testes: [...fontes.keys()].filter(ehEntradaDeTeste) }
414    }
415    let mapa: Awaited<ReturnType<typeof mapear>>
416
417    /** Quem roda os testes numa cópia: os comandos dela, os testes que ela escondeu e como rodar. */
418    const trabalhador = (dir: string) => {
419      const comandosDaCopia = comandos.map(c => ({ ...c, argv: c.argv.map(x => x.replaceAll('{COPIA}', dir)) }))
420      const rodarUm = async (argv: string[]) =>
421        $.process.run(argv, { cwd: dir, timeoutMs, env: AMBIENTE_DE_TESTE }).catch(err => ({
422          exitCode: -1,
423          stdout: '',
424          stderr: `não rodou: ${err instanceof Error ? err.message : String(err)}`,
425        }))
426      /** Roda os comandos em ordem e para no primeiro que falhar; os testes somam os de cada comando que rodou. */
427      const rodar = async (cs: Comando[]) => {
428        const t = Date.now()
429        let ultimo = { exitCode: 0, stdout: '', stderr: '' }
430        let testes: number | undefined = 0
431        for (const c of cs) {
432          ultimo = await rodarUm(c.argv)
433          testes = somarTestes(testes, contarTestes(`${ultimo.stdout}\n${ultimo.stderr}`))
434          if (ultimo.exitCode !== 0) break
435        }
436        return { ...ultimo, testes, segundos: (Date.now() - t) / 1000 }
437      }
438      const escondidos = new Set<string>()
439      /** Esconde na cópia os arquivos de teste fora de `quero`, renomeando; mexe só no que mudou desde a última vez. */
440      const ajustar = async (quero: Set<string>) => {
441        const mv = async (fs: string[], de: string, para: string) => {
442          if (!fs.length) return
443          const r = await $.process.run(['sh', '-c', `for f; do mv -- "$f${de}" "$f${para}"; done`, 'sh', ...fs], { cwd: dir })
444          if (r.exitCode !== 0) throw new Error(`não consegui ${para ? 'esconder' : 'mostrar'} testes na cópia: ${r.stderr.trim()}`)
445        }
446        const esconder = (mapa?.testes ?? []).filter(t => !quero.has(t) && !escondidos.has(t))
447        const mostrar = [...escondidos].filter(t => quero.has(t))
448        await mv(esconder, '', SUFIXO_FORA)
449        esconder.forEach(t => escondidos.add(t))
450        await mv(mostrar, SUFIXO_FORA, '')
451        mostrar.forEach(t => escondidos.delete(t))
452      }
453      const todos = () => ajustar(new Set(mapa?.testes ?? []))
454      return { dir, comandos: comandosDaCopia, rodar, ajustar, todos }
455    }
456    type Trabalhador = ReturnType<typeof trabalhador>
457    const primeiro = trabalhador(copia)
458
459    await aoVivo('base')
460    const base = await primeiro.rodar(primeiro.comandos)
461    const lsTree = await git($, ['ls-tree', '-r', '-l', 'HEAD'], raiz)
462
463    // O mapa da execução incremental: lido da cópia uma vez; sem ele, cada mutante roda a suíte inteira.
464    mapa = a.incremental === false || base.exitCode !== 0 ? undefined : await mapear()
465    const nproc = await $.process.run(['nproc']).catch(() => undefined)
466    const nucleos = nproc?.exitCode === 0 ? Number.parseInt(nproc.stdout.trim(), 10) || undefined : undefined
467    const paralelo: Paralelo = { copias: 1, ...(nucleos ? { nucleos } : {}) }
468    const exec: Execucao = {
469      tarefa: a.tarefa.trim(),
470      modelo: e.modelo,
471      comando: primeiro.comandos.map(c => c.argv.join(' ').replaceAll(copia, '<cópia>')),
472      enviados: e.enviados,
473      excluidos: e.excluidos,
474      naoCodigo: e.naoCodigo,
475      avisoNovos: e.avisoNovos,
476      avisoCorte: e.avisoCorte,
477      naoAplicaveis: e.naoAplicaveis,
478      linhaDeBase: { passou: base.exitCode === 0, segundos: base.segundos, saida: base.exitCode === 0 ? undefined : (base.stdout + base.stderr).slice(-1500) },
479      resultados: [],
480      restauracao: 'cmp na cópia: idêntico',
481      originalIntocado: true,
482      segundos: 0,
483      custo: {
484        repo: lsTree.exitCode === 0 ? tamanhoDoRepo(lsTree.stdout) : undefined,
485        diff: { arquivos: e.enviados.length, linhas: e.linhasDoDiff ?? 0 },
486        testesNaBase: base.testes,
487      },
488      paralelo,
489    }
490    /**
491     * R12: o tsc nas pastas tocadas que têm tsconfig, na cópia; havendo erro, também numa cópia voltada à base, e só
492     * o erro novo reprova (tipos.ts). Sem tsc, a rodada segue e o relatório avisa que os tipos ficaram de fora.
493     */
494    const checarTipos = async (): Promise<Tipos | undefined> => {
495      const candidatas = [...new Set(comandos.map(c => c.pasta ?? ''))]
496      const comTsconfig = new Set<string>()
497      for (const p of candidatas) if (await $.fs.exists(`${copia}/${p ? `${p}/` : ''}tsconfig.json`)) comTsconfig.add(p)
498      const pastas = pastasParaTipos(candidatas, p => comTsconfig.has(p), e.enviados)
499      if (!pastas.length) return undefined
500      const achou = await $.process.run(['sh', '-c', ACHAR_TSC, 'sh', copia, (await $.env.get('HOME')) ?? '']).catch(() => undefined)
501      const tsc = achou?.exitCode === 0 ? (achou.stdout.trim().split('\n')[0] ?? '') : ''
502      if (!tsc) return { pastas, novos: [], antigos: 0, aviso: 'tsc não encontrado (no repositório, no cache do npx ou no PATH): os tipos ficaram de fora desta prova' }
503      const argv = /typescript\/bin\/tsc$/.test(tsc) ? ['node', tsc] : [tsc]
504      const checar = async (dir: string) => {
505        const erros = []
506        for (const p of pastas) {
507          const r = await $.process
508            .run([...argv, '-p', '.', '--pretty', 'false'], { cwd: p ? `${dir}/${p}` : dir, timeoutMs })
509            .catch(err => ({ exitCode: -1, stdout: '', stderr: err instanceof Error ? err.message : String(err) }))
510          const achados = errosDeTipo(`${r.stdout}\n${r.stderr}`, p)
511          if (r.exitCode !== 0 && !achados.length) return { erros, ilegivel: `${p || '.'}: ${(r.stdout + r.stderr).trim().slice(-300) || `código de saída ${r.exitCode}`}` }
512          erros.push(...achados)
513        }
514        return { erros }
515      }
516      const agora = await checar(copia)
517      if (agora.ilegivel) return { pastas, novos: [], antigos: 0, aviso: `o tsc falhou sem erro legível (${agora.ilegivel}): os tipos ficaram de fora desta prova` }
518      if (!agora.erros.length) return { pastas, novos: [], antigos: 0 }
519      const dirBase = `${pasta}/base-tipos`
520      const naBase = (await preparar(dirBase, `${pasta}/realocado-tipos`)) ?? (await voltarABase(dirBase))
521      const base = naBase ? undefined : await checar(dirBase)
522      if (!base || base.ilegivel)
523        return { pastas, novos: agora.erros, antigos: 0, aviso: `sem a base para comparar (${naBase ?? base?.ilegivel}): todo erro de tipo conta como novo` }
524      const novos = errosNovos(base.erros, agora.erros)
525      return { pastas, novos, antigos: agora.erros.length - novos.length }
526    }
527    /** A cópia volta à base nas pastas checadas: o índice e os arquivos dela; o que não existia na base sai (o ignorado fica). */
528    const voltarABase = async (dir: string): Promise<string | undefined> => {
529      // .git em arquivo (worktree) aponta para o repositório original: o reset mexeria no índice dele.
530      const git = await $.fs.stat(`${dir}/.git`).catch(() => undefined)
531      if (git?.kind !== 'dir') return 'o .git da cópia não é uma pasta própria (worktree?): voltar à base mexeria no original'
532      const r = await $.process.run(['sh', '-c', VOLTAR_A_BASE, 'sh', e.base || 'HEAD'], { cwd: dir })
533      return r.exitCode === 0 ? undefined : `a volta à base ${e.base || 'HEAD'} falhou: ${r.stderr.trim()}`
534    }
535    if (exec.linhaDeBase.passou) exec.tipos = await checarTipos()
536
537    const trabalhadores: Trabalhador[] = [primeiro]
538    if (exec.linhaDeBase.passou && !exec.tipos?.novos.length) {
539      // As outras cópias. Antes de dividir os mutantes, a linha de base roda em todas ao mesmo tempo: se falha
540      // assim e passou sozinha, as suítes disputam algo (uma porta, um arquivo fora da cópia) e a rodada vai em fila.
541      const largura = larguraDoParalelo(a.paralelo, nucleos, e.mutantes.length)
542      for (let k = 2; k <= largura; k++) {
543        const erro = await preparar(`${pasta}/repo-${k}`, `${pasta}/realocado-${k}`)
544        if (erro) {
545          paralelo.motivo = `a cópia ${k} não ficou pronta (${erro})`
546          break
547        }
548        trabalhadores.push(trabalhador(`${pasta}/repo-${k}`))
549      }
550      if (paralelo.motivo) trabalhadores.splice(1)
551      if (trabalhadores.length > 1) {
552        const bases = await Promise.all(trabalhadores.map(t => t.rodar(t.comandos)))
553        if (bases.some(b => b.exitCode !== 0)) {
554          paralelo.motivo = `a linha de base falhou com ${trabalhadores.length} cópias ao mesmo tempo e passou sozinha (porta, arquivo ou outro recurso que as suítes disputam)`
555          trabalhadores.splice(1)
556        }
557      }
558      paralelo.copias = trabalhadores.length
559      vivo.copias = trabalhadores.length
560
561      /** Por recorte: se ele passa sem mutante (ver chaveDoRecorte); a chave não depende da cópia, e cada recorte roda uma vez. */
562      const basesDoRecorte = new Map<string, Promise<boolean>>()
563      const semCopia = (t: Trabalhador, cs: Comando[]) => cs.map(c => ({ ...c, argv: c.argv.map(x => x.replaceAll(t.dir, '{COPIA}')) }))
564      const processar = async (w: Trabalhador, m: Mutante, r: Resultado) => {
565        const alvo = resolverArquivo(m.arquivo, e.enviados)
566        if ('erro' in alvo) {
567          r.motivo = alvo.erro
568          return
569        }
570        r.arquivo = alvo.arquivo
571        // Sem comando que cubra o arquivo, "sobreviver" não diria nada: nem roda.
572        const cobrem = comandosPara(alvo.arquivo, w.comandos)
573        if (!cobrem.length) {
574          r.motivo = 'nenhum comando de teste cobre este arquivo'
575          return
576        }
577        const caminho = `${w.dir}/${alvo.arquivo}`
578        const original = await $.fs.read(caminho)
579        const ap = aplicarTrecho(original, m.linha, m.mutacao)
580        if ('erro' in ap) {
581          r.motivo = ap.erro
582          return
583        }
584        r.linhaReal = ap.linhaReal
585        r.ocorrencias = ap.ocorrencias
586        mutados.set(alvo.arquivo, (mutados.get(alvo.arquivo) ?? new Set()).add(ap.texto))
587        let plano = planoIncremental(alvo.arquivo, mapa, cobrem)
588        if (plano.alcancam) {
589          // Ainda sem o mutante: o recorte novo roda uma vez no código original; falhando, a suíte inteira decide.
590          const chave = chaveDoRecorte(plano.alcancam, semCopia(w, plano.comandos))
591          if (!basesDoRecorte.has(chave)) {
592            const alcancam = new Set(plano.alcancam)
593            const comandosDoRecorte = plano.comandos
594            basesDoRecorte.set(chave, (async () => (await w.ajustar(alcancam), (await w.rodar(comandosDoRecorte)).exitCode === 0))())
595          }
596          if (!(await basesDoRecorte.get(chave))) plano = { escopo: `suíte inteira (o recorte de ${plano.alcancam.length} arquivos de teste falha sem mutante)`, comandos: cobrem }
597        }
598        await $.fs.write(caminho, ap.texto)
599        try {
600          r.escopo = plano.escopo
601          let t: Awaited<ReturnType<Trabalhador['rodar']>>
602          if (plano.alcancam) {
603            await w.ajustar(new Set(plano.alcancam))
604            const inc = await w.rodar(plano.comandos)
605            const saidaInc = inc.stdout + '\n' + inc.stderr
606            // Morte só vale com um teste nomeado; sobrevivente, carga quebrada ou falha sem nome: a suíte inteira decide.
607            const morteNomeada = inc.exitCode !== 0 && !invalido(saidaInc) && !/^código de saída/.test(quemMatou(saidaInc, inc.exitCode))
608            if (morteNomeada) t = inc
609            else {
610              await w.todos()
611              const cheia = await w.rodar(cobrem)
612              r.escopo = `${plano.escopo}, ${inc.exitCode === 0 ? 'sobrevivente confirmado' : 'falha sem nome refeita'} na suíte inteira`
613              t = { ...cheia, segundos: inc.segundos + cheia.segundos, testes: somarTestes(inc.testes, cheia.testes) }
614            }
615          } else {
616            await w.todos()
617            t = await w.rodar(cobrem)
618          }
619          r.segundos = t.segundos
620          const saida = t.stdout + '\n' + t.stderr
621          r.testes = t.testes
622          if (t.exitCode !== 0 && invalido(saida)) {
623            r.motivo = `mutante inválido: o código mutado não carregou (${erroDeCarga(saida)}); não conta como morto`
624          } else {
625            r.situacao = t.exitCode === 0 ? 'SOBREVIVENTE' : 'MORTO'
626            if (t.exitCode !== 0) r.motivo = quemMatou(saida, t.exitCode)
627          }
628        } finally {
629          await $.fs.write(caminho, original)
630          if ((await $.fs.read(caminho)) !== original) exec.restauracao = 'cmp na cópia: DIFERENTE'
631        }
632      }
633
634      // Os mutantes saem de uma fila só, na ordem da lista; o número de cada um é o da lista, rode em que cópia rodar.
635      const fila = e.mutantes.map((m, i) => {
636        const r: Resultado = { ...m, n: i + 1, situacao: 'NÃO APLICADO', segundos: 0 }
637        exec.resultados.push(r)
638        return { m, r }
639      })
640      vivo.resultados = exec.resultados
641      await aoVivo('mutantes')
642      let proximo = 0
643      // allSettled: se uma cópia lança, as outras terminam antes de a pasta ser apagada; depois o erro sobe.
644      const feitos = await Promise.allSettled(
645        trabalhadores.map(async t => {
646          while (proximo < fila.length) {
647            const { m, r } = fila[proximo++]!
648            await processar(t, m, r)
649            vivo.feitos++
650            await aoVivo('mutantes')
651          }
652        }),
653      )
654      const falhou = feitos.find((x): x is PromiseRejectedResult => x.status === 'rejected')
655      if (falhou) throw falhou.reason
656      if (trabalhadores.length > 1) {
657        // Morte sem teste nomeado pode ser a disputa por CPU (tempo esgotado): refeita sozinha, na primeira cópia.
658        const refazer = fila.filter(({ r }) => refazerSozinho(r))
659        for (const { m, r } of refazer) {
660          Object.assign(r, { situacao: 'NÃO APLICADO', segundos: 0, testes: undefined, escopo: undefined, motivo: undefined })
661          await processar(primeiro, m, r)
662          r.escopo = `${r.escopo ?? 'suíte inteira'}, refeito sozinho (morte sem nome em paralelo)`
663        }
664        paralelo.refeitos = refazer.length
665      }
666    }
667    // Os testes escondidos voltam antes de conferir a cópia; se um mv falhar, ajustar lança e a rodada não sai.
668    for (const t of trabalhadores) await t.todos()
669    for (const [arquivo, textos] of mutados) {
670      const agora = await $.fs.read(`${raiz}/${arquivo}`).catch(() => undefined)
671      if (agora !== undefined && textos.has(agora)) exec.originalIntocado = false
672    }
673    exec.mudouPorFora = (await git($, ['status', '--porcelain'], raiz)).stdout !== statusAntes
674    exec.segundos = (Date.now() - inicio) / 1000
675    const c = contar(exec.resultados)
676    const feita: ExecucaoFeita = {
677      tarefa: exec.tarefa,
678      raiz,
679      ...c,
680      estado: estadoDe(exec),
681      estadoSeEquivalentes: estadoSeEquivalentes(exec),
682      sobreviventesN: exec.resultados.filter(x => x.situacao === 'SOBREVIVENTE').map(x => x.n),
683      descobertas: categoriasDescobertas(exec.resultados, exec.naoAplicaveis),
684      quando: new Date().toISOString(),
685      versao: await versaoDoMod($),
686      // O diff mexe no próprio mod: a rodada mede o instrumento enquanto ele muda (v1.2: calibração).
687      alteraInstrumento: tocaOInstrumento(
688        raiz,
689        e.enviados,
690        $.plugin.root,
691        await pluginsTocados($, raiz, e.enviados),
692        nomeDe(await $.fs.read(`${$.plugin.root}/.claude-plugin/plugin.json`).catch(() => '')),
693      ),
694      custo: resumoCusto(exec),
695    }
696    await update($, execucao, () => feita)
697    vivo.resultados = exec.resultados
698    await aoVivo('fim', feita.estado)
699    const texto = relatorio(exec, new Date().toISOString().slice(0, 10))
700    const saidaBase = exec.linhaDeBase.saida ? `\n\nSaída da linha de base (fim):\n${exec.linhaDeBase.saida}` : ''
701    return { texto: `${texto}${saidaBase}\n\nRegistre com ${TOOL_REGISTRAR}: aplicados ${c.aplicados}, mortos ${c.mortos}, sobreviventes ${c.sobreviventes}.`, erro: false }
702  } finally {
703    // Rodada que saiu no meio (erro, cópia que falhou): o painel não fica mostrando uma prova que não roda mais.
704    if (!fimPublicado && vivo.total) await aoVivo('fim', 'interrompida')
705    // Só apaga a pasta que o próprio mod criou.
706    if (pasta.includes(PREFIXO_TMP)) await $.process.run(['rm', '-rf', '--', pasta]).catch(() => undefined)
707  }
708}
709
710export const register: Register = on => {
711  on('session.start', async ($, e, next) => {
712    await $.tool.register(SPEC_MUTANTES)
713    await $.tool.register(SPEC_REGISTRAR)
714    await $.tool.register(SPEC_EXECUTAR)
715    await $.command.register({
716      name: 'prova',
717      description: 'EXP-001: mutantes escolhidos às cegas para o diff de um repositório (pasta padrão: a da sessão; base padrão: HEAD)',
718      argumentHint: '[pasta] [base]',
719    })
720    await publicarPlacar($).catch(() => undefined)
721    return next(e)
722  })
723
724  on('tool.call', { tool: TOOL_MUTANTES as never }, async ($, e) => {
725    const r = await escolher($, e as never).catch(falha)
726    return { result: r.texto, ...(r.erro ? { isError: true } : {}) } as never
727  })
728
729  on('tool.call', { tool: TOOL_REGISTRAR as never }, async ($, e) => {
730    const r = await registrar($, e as never).catch(falha)
731    return { result: r.texto, ...(r.erro ? { isError: true } : {}) } as never
732  })
733
734  on('tool.call', { tool: TOOL_EXECUTAR as never }, async ($, e) => {
735    const r = await executar($, e as never).catch(falha)
736    return { result: r.texto, ...(r.erro ? { isError: true } : {}) } as never
737  })
738
739  on('command.run', { command: 'prova' }, async ($, e) => {
740    const home = (await $.env.get('HOME').catch(() => undefined)) ?? ''
741    const r = await escolher($, lerArgs(e.args, home)).catch(falha)
742    return { text: r.texto }
743  })
744
745  // Uma edição de arquivo de teste na conversa principal marca a tarefa como "com teste": um lembrete por turno.
746  on('tool.call', async ($, e, next) => {
747    const result = await next(e)
748    if (result.deny !== undefined || result.isError) return result
749    // O lembrete é acessório: nenhuma falha aqui pode atrapalhar a edição em si.
750    const aviso = await lembrete($, e).catch(() => undefined)
751    return aviso ? { ...result, context: [...(result.context ?? []), aviso] } : result
752  })
753}
754
755async function lembrete(
756  $: EngineInterface,
757  e: { agentId?: string; tool: unknown },
758): Promise<string | undefined> {
759  {
760    if (e.agentId || !EDICAO.has(e.tool as string)) return undefined
761    const caminho = String((e as { file_path?: string }).file_path ?? (e as { notebook_path?: string }).notebook_path ?? '')
762    if (!ehArquivoDeTeste(caminho)) return undefined
763    const turno = await $.session.turns()
764    if ((await read($, avisadoTurno)) === turno) return undefined
765    await update($, avisadoTurno, () => turno)
766    return (
767      `[prova-de-teste] Esta tarefa mexe em teste (${caminho}). Vale o EXP-001 v1.2: leia ${protocoloEm($.plugin.root)}; ` +
768      `red first; depois chame ${TOOL_MUTANTES} para os mutantes às cegas (R2.1), aplique todos e registre com ${TOOL_REGISTRAR}.`
769    )
770  }
771}
772
773const falha = (err: unknown) => ({ texto: `Erro: ${err instanceof Error ? err.message : String(err)}`, erro: true })
774
hooks/execucao.ts 579 lines
1import type { Categoria, Mutante } from '../types'
2import { CATEGORIAS, ehArquivoDeTeste } from './prova'
3import { testesQueAlcancam } from './alcance'
4import { linhaTipos } from './tipos'
5import type { Tipos } from './tipos'
6
7/** Uma mutação no formato `trecho original` → `trecho mutado` (aceita também ``a`` com crases duplas). */
8export function lerMutacao(mutacao: string): { de: string; para: string } | null {
9  return lerMutacoes(mutacao)[0] ?? null
10}
11
12/**
13 * Todas as leituras possíveis da mutação: o trecho pode ter crase e seta por dentro, então cada
14 * "` → `" é um ponto de corte candidato. Quem aplica fica com o maior original que existe no arquivo.
15 */
16export function lerMutacoes(mutacao: string): { de: string; para: string }[] {
17  // O escolhedor às vezes explica a mutação depois dela: `a` → `b` (remove a guarda). A nota não faz parte do trecho.
18  const t = mutacao.trim().replace(/(`)\s+\([^`]*\)$/, '$1')
19  const dupla = /^``\s(.+?)\s``\s*→\s*``\s(.*?)\s``$/.exec(t)
20  if (dupla) return comEscapes([{ de: dupla[1]!, para: dupla[2]! }])
21  if (!t.startsWith('`') || !t.endsWith('`')) return []
22  const miolo = t.slice(1, -1)
23  const leituras: { de: string; para: string }[] = []
24  for (const corte of miolo.matchAll(/`\s*→\s*`/g)) {
25    const de = miolo.slice(0, corte.index)
26    if (de) leituras.push(...delimitadores(de, miolo.slice(corte.index! + corte[0].length)))
27  }
28  return comEscapes(leituras)
29}
30
31/**
32 * Crases duplas de um lado só (``a`` → `b`, sem espaços): sobra uma crase de delimitador no começo do original e no
33 * fim, ou no mutado. Com sobra no original, a leitura literal fica primeiro e a sem sobra vem para quando a literal
34 * não existe no arquivo; com sobra só no mutado, a sem sobra vem primeiro. Recusado como "trecho não encontrado" na rodada do caderno (contexto-bar 0.12.0).
35 */
36function delimitadores(de: string, para: string): { de: string; para: string }[] {
37  const tira = (s: string) => (s.length > 2 && s.startsWith('`') && s.endsWith('`') ? s.slice(1, -1).trim() : s)
38  const sem = { de: tira(de), para: tira(para) }
39  if (sem.de === de && sem.para === para) return [{ de, para }]
40  // Com o original intacto, as duas leituras acham o mesmo trecho e vale a primeira: a crase de sobra era do mutado.
41  return sem.de === de ? [sem, { de, para }] : [{ de, para }, sem]
42}
43
44/**
45 * O escolhedor escapa a crase de dentro do trecho (`` `\`${a}\`` ``) e a barra vertical, que separa os campos da
46 * linha (`a \|\| b`): cada leitura com \` ou \| ganha a versão sem a barra. As duas ficam; quem aplica fica com a que
47 * existe no arquivo (código com \` ou \| de verdade, como numa regex, continua achando a literal).
48 * Recusados como "não encontrado" 4 vezes num dia pela crase (0.8.3) e 3 vezes numa rodada pela barra (0.9.0).
49 */
50// Sem a flag g no teste: uma regex global guarda a posição entre chamadas e erraria a segunda leitura.
51const TEM_ESCAPE = /\\[`|]/
52function comEscapes(leituras: { de: string; para: string }[]): { de: string; para: string }[] {
53  const tira = (s: string) => s.replace(/\\([`|])/g, '$1')
54  return leituras.flatMap(l => {
55    if (!TEM_ESCAPE.test(l.de) && !TEM_ESCAPE.test(l.para)) return [l]
56    const sem = { de: tira(l.de), para: tira(l.para) }
57    // Com o original sem escape, as duas leituras acham o mesmo trecho e vale a primeira: a barra do mutado era só
58    // do formato da linha, então a versão sem ela vem antes.
59    return TEM_ESCAPE.test(l.de) ? [l, sem] : [sem, l]
60  })
61}
62
63export type Aplicacao = { texto: string; linhaReal: number; ocorrencias: number }
64
65/**
66 * Aplica a mutação pelo trecho: o número de linha do escolhedor costuma errar, o trecho não.
67 * Com mais de uma ocorrência, vale a mais próxima da linha indicada.
68 */
69export function aplicarTrecho(fonte: string, linha: number, mutacao: string): Aplicacao | { erro: string } {
70  const leituras = lerMutacoes(mutacao)
71  if (!leituras.length) return { erro: 'mutação fora do formato `original` → `mutado`' }
72  // Um corte cedo demais também acha um prefixo no arquivo: vale o maior original que existe nele.
73  const achadas = leituras.filter(l => fonte.includes(l.de)).sort((x, y) => y.de.length - x.de.length)
74  const m = achadas[0] ?? leituras[0]!
75  // Proteção dupla: buscar "" não termina nunca.
76  if (!m.de) return { erro: 'mutação fora do formato `original` → `mutado`' }
77  if (m.de === m.para) return { erro: 'mutação não muda nada' }
78  const posicoes: number[] = []
79  for (let i = fonte.indexOf(m.de); i !== -1; i = fonte.indexOf(m.de, i + 1)) posicoes.push(i)
80  if (!posicoes.length) return { erro: 'trecho original não encontrado no arquivo' }
81  const linhaDe = (i: number) => fonte.slice(0, i).split('\n').length
82  const melhor = posicoes.reduce((a, b) => (Math.abs(linhaDe(b) - linha) < Math.abs(linhaDe(a) - linha) ? b : a))
83  return {
84    texto: fonte.slice(0, melhor) + m.para + fonte.slice(melhor + m.de.length),
85    linhaReal: linhaDe(melhor),
86    ocorrencias: posicoes.length,
87  }
88}
89
90/** Tira as cores ANSI: o vitest colore o resumo mesmo sem terminal quando FORCE_COLOR está no ambiente (projeto real, 0.8.1). */
91export const semCor = (s: string) => s.replace(/\x1b\[[0-9;]*m/g, '')
92
93/**
94 * O mutante quebrou o carregamento (sintaxe, tipos, import): é inválido, não conta como morto.
95 * Só vale o erro na coluna 0, onde o carregador o escreve; o lançado dentro de um teste sai indentado
96 * sob o nome do teste e é morte (um JSON.parse que falha não é arquivo que não carregou).
97 */
98export function invalido(saida: string): boolean {
99  return erroDeCarga(saida) !== undefined
100}
101
102/** A primeira linha do erro de carga, para o relatório dizer por que o mutante não carregou. */
103export function erroDeCarga(texto: string): string | undefined {
104  const linhas = semCor(texto).split('\n')
105  // Na coluna 0, como o claude plugin test escreve; o mesmo texto citado no código de um teste que falhou não vale.
106  const i = linhas.findIndex(l => /^\(fail\) the file did not load/.test(l))
107  if (i >= 0) return linhas.slice(i + 1).find(l => l.trim())?.trim() ?? 'the file did not load'
108  // pytest: o módulo de teste não importou (saída 2). Vale a última linha E do bloco "ERROR collecting", que é
109  // a exceção (as de cima são o trecho do código); E de um teste que falhou, antes do bloco, é morte.
110  const c = linhas.findIndex(l => /^_+ ERROR collecting .+ _+$/.test(l))
111  if (c >= 0) {
112    const fim = linhas.findIndex((l, j) => j > c && /^(?:_{3,}|={3,}) /.test(l))
113    const bloco = linhas.slice(c + 1, fim < 0 ? undefined : fim)
114    return bloco.findLast(l => /^E +\S/.test(l))?.replace(/^E +/, '') ?? 'ERROR collecting'
115  }
116  return linhas.find(l => /^(?:SyntaxError|error TS\d{4}|Error: Cannot find module|ParseError)/.test(l))
117}
118
119/** O nome do teste que falhou, pelos formatos comuns; sem nenhum, o código de saída. */
120export function quemMatou(texto: string, codigo: number): string {
121  const saida = semCor(texto)
122  const padroes = [
123    /^\s*\(fail\)\s+(.+?)(?:\s+\[[\d.]+m?s\])?\s*$/m, // claude plugin test, bun test
124    /^\s*[✕×]\s+(.+?)(?:\s+\(\d+\s*m?s\)|\s+\d+(?:\.\d+)?m?s)?\s*$/m, // jest "(1 ms)", vitest "9ms"
125    /^\s*✖\s+(?!failing tests:)(.+?)(?:\s+\([\d.]+m?s\))?\s*$/m, // node --test (spec)
126    /^\s*not ok \d+ - (.+?)\s*(?:#.*)?$/m, // node --test (tap)
127    /^FAILED\s+(\S+)/m, // pytest
128    /^\s*FAIL\s+(\S.*)$/m, // jest/vitest por arquivo
129  ]
130  for (const p of padroes) {
131    const m = p.exec(saida)
132    if (m) return m[1]!.trim()
133  }
134  return `código de saída ${codigo}`
135}
136
137/**
138 * Quantos testes a execução rodou, pelo resumo final dos formatos comuns; sem resumo, nada.
139 * É o número que diz se a execução incremental rodou menos testes por mutante.
140 */
141export function contarTestes(texto: string): number | undefined {
142  const saida = semCor(texto)
143  const num = (re: RegExp) => {
144    const m = re.exec(saida)
145    return m ? Number(m[1]) : undefined
146  }
147  // claude plugin test e bun test: "Ran 46 tests across 3 files."
148  const ran = num(/^Ran (\d+) tests? across/m)
149  if (ran !== undefined) return ran
150  // jest e vitest: "Tests:  1 failed, 11 passed, 12 total" / "Tests  12 passed (12)"
151  const jest = num(/^\s*Tests:.*?(\d+) total/m) ?? num(/^\s*Tests\s+.*\((\d+)\)\s*$/m)
152  if (jest !== undefined) return jest
153  // node --test: "# tests 12" (tap) ou "ℹ tests 12" (spec)
154  const node = num(/^(?:#|ℹ) tests (\d+)/m)
155  if (node !== undefined) return node
156  // pytest: "3 failed, 9 passed, 1 skipped in 0.31s" (a linha final, com ou sem os "=")
157  const py = /^[= ]*((?:\d+ (?:passed|failed|errors?|skipped|xfailed|xpassed)(?:, )?)+) in [\d.]+s/m.exec(saida)
158  if (py) return [...py[1]!.matchAll(/(\d+) (passed|failed|errors?)/g)].reduce((t, m) => t + Number(m[1]), 0)
159  return undefined
160}
161
162/** Soma os testes de dois comandos; um sem resumo deixa a soma desconhecida, em vez de contar zero. */
163export const somarTestes = (a: number | undefined, b: number | undefined) => (a === undefined || b === undefined ? undefined : a + b)
164
165/** Linhas adicionadas ou removidas num diff: só dentro dos hunks, para "--- x" removido não passar por cabeçalho. */
166export function linhasDoDiff(texto: string): number {
167  let noHunk = false
168  let n = 0
169  for (const l of texto.split('\n')) {
170    if (l.startsWith('diff --git ')) noHunk = false
171    else if (l.startsWith('@@')) noHunk = true
172    else if (noHunk && /^[+-]/.test(l)) n++
173  }
174  return n
175}
176
177/** Arquivos e bytes do repositório, pela saída de `git ls-tree -r -l HEAD`. */
178export function tamanhoDoRepo(lsTree: string): { arquivos: number; bytes: number } {
179  const linhas = lsTree.split('\n').filter(l => /\sblob\s/.test(l))
180  const bytes = linhas.reduce((t, l) => t + (Number(/\sblob\s+\S+\s+(\d+)\t/.exec(l)?.[1]) || 0), 0)
181  return { arquivos: linhas.length, bytes }
182}
183
184/** O que a rodada custou e sobre o quê: os números da hipótese "o custo acompanha o diff, não o código". */
185export type Paralelo = { copias: number; nucleos?: number; motivo?: string; refeitos?: number }
186
187/** Teto do automático: além disso, a máquina do operador (32 núcleos) já ficaria disputada pelas próprias suítes. */
188export const MAX_COPIAS = 8
189
190/**
191 * Quantas cópias rodam mutantes ao mesmo tempo. Pedido explícito vale (até o número de mutantes); sem pedido, um
192 * quarto dos núcleos, até MAX_COPIAS: a suíte de um mutante também usa CPU. Sem saber os núcleos, uma (a fila).
193 * Na rodada de 25 mutantes de 15/10, em fila, a máquina ficou a 0,37 de 32 e a rodada levou 1.441 s.
194 */
195export function larguraDoParalelo(pedido: number | undefined, nucleos: number | undefined, mutantes: number): number {
196  const teto = Math.max(1, mutantes)
197  if (pedido !== undefined && Number.isFinite(pedido)) return Math.min(teto, Math.max(1, Math.floor(pedido)))
198  if (!nucleos || !Number.isFinite(nucleos)) return 1
199  return Math.min(teto, MAX_COPIAS, Math.max(1, Math.floor(nucleos / 4)))
200}
201
202/** Morte sem teste nomeado (tempo esgotado, saída sem nome) em paralelo pode ser a disputa por CPU: refaz sozinho. */
203export const refazerSozinho = (r: Pick<Resultado, 'situacao' | 'motivo'>) => r.situacao === 'MORTO' && /^código de saída/.test(r.motivo ?? '')
204
205/** A linha do relatório; nada quando a rodada foi em fila sem motivo a dizer. */
206export function linhaParalelo(p: Paralelo | undefined): string {
207  if (!p || (p.copias <= 1 && !p.motivo)) return ''
208  const nucleos = p.nucleos ? ` (${p.nucleos} núcleos)` : ''
209  if (p.copias <= 1) return `Paralelo:   em fila${nucleos}: ${p.motivo}`
210  return `Paralelo:   ${p.copias} cópias ao mesmo tempo${nucleos}${p.refeitos ? `; ${p.refeitos} morte(s) sem nome refeita(s) sozinha(s)` : ''}`
211}
212
213export type Custo = {
214  repo?: { arquivos: number; bytes: number }
215  diff: { arquivos: number; linhas: number }
216  testesNaBase?: number
217}
218
219/** Médias por mutante aplicado (os não aplicados não rodaram). */
220export function porMutante(rs: Resultado[]): { n: number; segundos: number; testes?: number } {
221  const ap = rs.filter(r => r.situacao !== 'NÃO APLICADO')
222  if (!ap.length) return { n: 0, segundos: 0 }
223  const comTestes = ap.filter(r => r.testes !== undefined)
224  return {
225    n: ap.length,
226    segundos: ap.reduce((t, r) => t + r.segundos, 0) / ap.length,
227    testes: comTestes.length === ap.length ? comTestes.reduce((t, r) => t + r.testes!, 0) / ap.length : undefined,
228  }
229}
230
231/** Quantos dos aplicados começaram incrementais; nada quando a rodada não registrou escopo. */
232function incrementais(rs: Resultado[]): string {
233  const ap = rs.filter(r => r.situacao !== 'NÃO APLICADO')
234  if (!ap.some(r => r.escopo)) return ''
235  return `, ${ap.filter(r => r.escopo?.startsWith('incremental')).length} incrementais`
236}
237
238const kb = (b: number) => (b >= 1024 * 1024 ? `${(b / 1024 / 1024).toFixed(1)} MB` : `${Math.round(b / 1024)} KB`)
239const um = (x: number) => (Number.isInteger(x) ? String(x) : x.toFixed(1))
240
241/** Uma linha só, igual no relatório e no log: base, por mutante, repositório e diff. */
242export function resumoCusto(e: Pick<Execucao, 'linhaDeBase' | 'resultados' | 'custo' | 'paralelo'>): string {
243  const pm = porMutante(e.resultados)
244  const c = e.custo
245  const testes = (n?: number) => (n === undefined ? '' : `, ${um(n)} testes`)
246  return [
247    `base ${e.linhaDeBase.segundos.toFixed(1)} s${testes(c?.testesNaBase)}`,
248    `por mutante ${pm.segundos.toFixed(1)} s${testes(pm.testes)} (n=${pm.n}${incrementais(e.resultados)})`,
249    c?.repo ? `repositório ${c.repo.arquivos} arquivos, ${kb(c.repo.bytes)}` : 'repositório ?',
250    c ? `diff ${c.diff.linhas} linhas em ${c.diff.arquivos} arquivos` : 'diff ?',
251    ...((e.paralelo?.copias ?? 1) > 1 ? [`${e.paralelo!.copias} cópias em paralelo`] : []),
252  ].join('; ')
253}
254
255export type ArquivosDoRepo = { pacote?: string; temPytest: boolean; pastasDePlugin: string[] }
256
257/** Um comando de teste; `pasta` é o plugin que ele cobre ('' = a raiz; ausente = o repositório todo). */
258export type Comando = { pasta?: string; argv: string[] }
259
260/** Comandos de teste do repositório, a partir do que existe nele e do que o diff tocou. */
261export function detectarComando(r: ArquivosDoRepo, tocados: string[], raiz: string): Comando[] {
262  // Cada plugin do Claude Code tocado pelo diff roda os próprios testes; a pasta '' é o plugin na raiz.
263  const plugins = r.pastasDePlugin.filter(p => tocados.some(t => p === '' || t === p || t.startsWith(`${p}/`)))
264  if (plugins.length) return plugins.map(p => ({ pasta: p, argv: ['claude', 'plugin', 'test', p ? `${raiz}/${p}` : raiz] }))
265  if (r.pacote) {
266    try {
267      const scripts = (JSON.parse(r.pacote) as { scripts?: Record<string, string> }).scripts
268      if (scripts?.test && !/no test specified/.test(scripts.test)) return [{ argv: ['npm', 'test', '--silent'] }]
269    } catch {
270      // package.json inválido: segue para os outros.
271    }
272  }
273  if (r.temPytest) return [{ argv: ['python3', '-m', 'pytest', '-q'] }]
274  return []
275}
276
277/**
278 * O ambiente de todo comando de teste. O CPython valida o .pyc pelo mtime do fonte em segundos inteiros e pelo tamanho:
279 * mutante do mesmo tamanho escrito no mesmo segundo do anterior rodava o bytecode velho (16 sobreviventes falsos em 20
280 * na reprodução, 15/10/2026), e o mutante restaurado no mesmo segundo seguia valendo nos mutantes de outros arquivos
281 * (12 mortes falsas em 15). Sem .pyc gravado, cada execução compila do fonte; o JS/TS ignora a variável.
282 */
283export const AMBIENTE_DE_TESTE: Record<string, string> = { PYTHONDONTWRITEBYTECODE: '1' }
284
285/**
286 * As pastas __pycache__ ao lado dos .py que podem ser mutados: a cópia leva as do original, e um .pyc que não é
287 * validado pelo fonte (unchecked-hash) seria lido em todo mutante. Só essas: as de dependências nunca mudam.
288 */
289export function pycachesDe(enviados: string[]): string[] {
290  const pastas = enviados.filter(f => /\.pyw?$/.test(f)).map(f => (f.includes('/') ? `${f.slice(0, f.lastIndexOf('/'))}/__pycache__` : '__pycache__'))
291  return [...new Set(pastas)]
292}
293
294/**
295 * Os arquivos do venv que guardam o caminho absoluto do projeto: o .pth da instalação editável, o egg-link, o
296 * localizador __editable__ do setuptools e os scripts de bin (shebang, activate). Copiados, ainda apontam para o original
297 * e a cópia importaria o código ORIGINAL (o 19/0/19 da 1ª passada do motor, rodada 59). O script lista, entre eles,
298 * só os de texto (grep -I) que citam a raiz ($1). Link sai depois, no $.fs.stat de quem reescreve: aqui não daria para testar.
299 */
300export const ACHAR_NO_VENV =
301  'for c in */pyvenv.cfg .*/pyvenv.cfg; do [ -f "$c" ] || continue; v=${c%/pyvenv.cfg}; ' +
302  'for f in "$v"/bin/* "$v"/lib*/python*/site-packages/*.pth "$v"/lib*/python*/site-packages/*.egg-link "$v"/lib*/python*/site-packages/__editable__*.py; do ' +
303  '[ -f "$f" ] && grep -qIF -e "$1" -- "$f" && printf "%s\\n" "$f"; done; done; true'
304
305/** Sobrescreve $2 com o conteúdo de $1 no mesmo arquivo: o modo (executável, nos scripts de bin) fica. */
306export const SOBRESCREVER = 'cat -- "$1" > "$2"'
307
308/**
309 * Troca a raiz pela cópia só onde ela é o caminho inteiro ou o começo de um (`/repo/src`, não `/repository`).
310 * Reescrever o que o venv já dizia, em vez de pôr PYTHONPATH, mantém a ordem e o layout que o projeto escolheu.
311 */
312export function realocar(texto: string, raiz: string, copia: string): string {
313  const r = raiz.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
314  return texto.replace(new RegExp(`(?<=^|[\\s"'=:;,(!])${r}(?=[/\\s"':;,)\\]]|$)`, 'gm'), copia)
315}
316
317export type Mapa = { g: Map<string, string[]>; testes: string[] }
318
319/**
320 * Por mutante: os arquivos de teste que alcançam o arquivo mutado e os comandos que os cobrem.
321 * Sem `alcancam`, roda a suíte inteira, e `escopo` diz por quê.
322 */
323export function planoIncremental(alvo: string, mapa: Mapa | undefined, cobrem: Comando[]): { escopo: string; alcancam?: string[]; comandos: Comando[] } {
324  const inteira = (porque: string) => ({ escopo: `suíte inteira (${porque})`, comandos: cobrem })
325  if (!mapa) return inteira('sem mapa de imports')
326  const naPasta = (c: Comando, x: string) => c.pasta === undefined || c.pasta === '' || x.startsWith(`${c.pasta}/`)
327  // Só contam os testes que algum dos comandos roda: o de outro plugin não entra na conta.
328  const noEscopo = mapa.testes.filter(x => cobrem.some(c => naPasta(c, x)))
329  const t = testesQueAlcancam(alvo, mapa.g).filter(x => noEscopo.includes(x))
330  if (!t.length) return inteira('nenhum teste importa o arquivo')
331  if (t.length >= noEscopo.length) return inteira('todos os testes alcançam o arquivo')
332  // Um comando de plugin sem nenhum dos testes na pasta dele não tem o que rodar.
333  const comandos = cobrem.filter(c => t.some(x => naPasta(c, x)))
334  return { escopo: `incremental: ${t.length}/${noEscopo.length} arquivos de teste`, alcancam: t, comandos }
335}
336
337/**
338 * O mesmo recorte (testes visíveis e comandos) dá a mesma chave. Cada recorte roda uma vez sem mutante antes de valer:
339 * um teste que lê a árvore do repositório falha com os testes escondidos, e a morte seria do recorte, não do mutante
340 * (rodada no publico, 0.8.2: SaidaReal "matou" mutantes do leitor_frio.py que, aplicados à mão, sobrevivem).
341 */
342export const chaveDoRecorte = (alcancam: string[], comandos: Comando[]) => `${[...alcancam].sort().join('\n')}\0${comandos.map(c => c.argv.join(' ')).join('\n')}`
343
344/** Os comandos que valem para um arquivo: os do plugin onde ele está e os do repositório todo. */
345export function comandosPara(arquivo: string, cs: Comando[]): Comando[] {
346  return cs.filter(c => c.pasta === undefined || c.pasta === '' || arquivo.startsWith(`${c.pasta}/`))
347}
348
349export type Resultado = Mutante & {
350  n: number
351  linhaReal?: number
352  ocorrencias?: number
353  situacao: 'MORTO' | 'SOBREVIVENTE' | 'NÃO APLICADO'
354  motivo?: string
355  segundos: number
356  /** Testes que a execução deste mutante rodou, pelo resumo do executor. */
357  testes?: number
358  /** Onde o mutante rodou: incremental (k/N arquivos de teste) ou a suíte inteira, e por quê. */
359  escopo?: string
360}
361
362export type Execucao = {
363  tarefa: string
364  modelo: string
365  comando: string[]
366  enviados: string[]
367  excluidos: string[]
368  naoCodigo?: string[]
369  avisoNovos?: string
370  avisoCorte?: string
371  naoAplicaveis: string[]
372  linhaDeBase: { passou: boolean; segundos: number; saida?: string }
373  /** A checagem de tipos da base (tipos.ts); erro novo reprova a linha de base. */
374  tipos?: Tipos
375  resultados: Resultado[]
376  restauracao: 'cmp na cópia: idêntico' | 'cmp na cópia: DIFERENTE'
377  /** Nenhum texto mutado chegou ao arquivo do original. */
378  originalIntocado: boolean
379  /** O git status do original mudou durante a rodada (commit ou edição de outra sessão); não invalida. */
380  mudouPorFora?: boolean
381  segundos: number
382  custo?: Custo
383  /** Quantas cópias rodaram mutantes ao mesmo tempo; o motivo quando ficou em fila sem ter sido pedido. */
384  paralelo?: Paralelo
385}
386
387export type Contagem = { aplicados: number; mortos: number; sobreviventes: number }
388
389export function contar(rs: Resultado[]): Contagem {
390  const aplicados = rs.filter(r => r.situacao !== 'NÃO APLICADO')
391  return {
392    aplicados: aplicados.length,
393    mortos: aplicados.filter(r => r.situacao === 'MORTO').length,
394    sobreviventes: aplicados.filter(r => r.situacao === 'SOBREVIVENTE').length,
395  }
396}
397
398/** Categorias aplicáveis sem nenhum mutante aplicado (os não aplicados não contam). */
399export function categoriasDescobertas(rs: Resultado[], naoAplicaveis: string[]): Exclude<Categoria, 'livre'>[] {
400  return CATEGORIAS.filter(
401    c => !rs.some(r => r.categoria === c && r.situacao !== 'NÃO APLICADO') && !naoAplicaveis.some(n => n.startsWith(`${c}:`)),
402  )
403}
404
405export function concentracao(rs: Resultado[]): { maior: number; total: number; teste?: string; alerta: boolean } {
406  const porTeste = new Map<string, number>()
407  for (const r of rs) if (r.situacao === 'MORTO' && r.motivo) porTeste.set(r.motivo, (porTeste.get(r.motivo) ?? 0) + 1)
408  const total = contar(rs).aplicados
409  let maior = 0
410  let teste: string | undefined
411  for (const [t, n] of porTeste) if (n > maior) [maior, teste] = [n, t]
412  return { maior, total, teste, alerta: total > 0 && maior * 2 > total }
413}
414
415export function estadoDe(e: Execucao): 'PROVADO' | 'FRACO' | 'NÃO VERIFICADO' {
416  if (!e.linhaDeBase.passou || e.tipos?.novos.length || !e.originalIntocado || e.restauracao !== 'cmp na cópia: idêntico') return 'NÃO VERIFICADO'
417  const c = contar(e.resultados)
418  if (c.aplicados === 0) return 'NÃO VERIFICADO'
419  if (c.sobreviventes > 0 || categoriasDescobertas(e.resultados, e.naoAplicaveis).length) return 'FRACO'
420  return 'PROVADO'
421}
422
423/** O estado se os sobreviventes forem equivalentes: o teto do que o agente pode alegar, justificando cada um. */
424export function estadoSeEquivalentes(e: Execucao): 'PROVADO' | 'FRACO' | 'NÃO VERIFICADO' {
425  return estadoDe({ ...e, resultados: e.resultados.map(r => (r.situacao === 'SOBREVIVENTE' ? { ...r, situacao: 'MORTO' as const } : r)) })
426}
427
428const GRAU = ['NÃO VERIFICADO', 'FRACO', 'PROVADO-PARCIAL', 'PROVADO']
429
430/**
431 * O estado registrado contra o que o mod executou. Igual ou pior passa. Melhor só com cada sobrevivente justificado
432 * como equivalente e sem passar do teto: "fora do escopo" não é equivalência (demo do claude-grounds, 14/10/2026).
433 */
434export function conferirEstado(
435  feita: { estado: string; estadoSeEquivalentes?: string; sobreviventesN?: number[]; descobertas?: string[] },
436  estado: string,
437  equivalentes: { n: number; motivo: string }[] = [],
438  manuais: { categoria: string }[] = [],
439): string | undefined {
440  if (!feita.sobreviventesN) return undefined
441  const estranhos = equivalentes.filter(q => !feita.sobreviventesN!.includes(q.n)).map(q => `#${q.n}`)
442  if (estranhos.length) return `${estranhos.join(', ')} não sobreviveu: só se justifica sobrevivente.`
443  if (GRAU.indexOf(estado) <= GRAU.indexOf(feita.estado)) return undefined
444  const faltam = feita.sobreviventesN.filter(n => !equivalentes.some(q => q.n === n && q.motivo.trim())).map(n => `#${n}`)
445  if (faltam.length)
446    return `o mod executou ${feita.estado}; para registrar ${estado}, justifique cada sobrevivente como equivalente (sem justificativa: ${faltam.join(', ')}). Fora do escopo não é equivalência: reforce o teste ou registre ${feita.estado}.`
447  // 0.14.0: o mutante manual morto cobre a categoria que a escolha deixou descoberta (só quando a execução diz quais).
448  const semManual = feita.descobertas?.filter(c => !manuais.some(m => m.categoria === c))
449  const teto = feita.estadoSeEquivalentes === 'FRACO' && feita.descobertas?.length && !semManual!.length ? 'PROVADO' : feita.estadoSeEquivalentes
450  if (teto !== 'PROVADO')
451    return `há categoria sem mutante${semManual?.length && teto === 'FRACO' ? ` (${semManual.join(', ')}: declare um mutante manual morto nela ou reforce)` : ''}: o estado máximo é ${feita.estadoSeEquivalentes ?? feita.estado}.`
452  if (estado !== 'PROVADO') return 'a escolha às cegas foi aplicada pelo mod: com os sobreviventes justificados, o estado é PROVADO.'
453  return undefined
454}
455
456const marca = (ok: boolean) => (ok ? '✔' : '✘')
457
458/** O relatório no formato do protocolo v1.1, escrito pelo mod a partir do que ele executou. */
459export function relatorio(e: Execucao, data: string): string {
460  const c = contar(e.resultados)
461  const conc = concentracao(e.resultados)
462  const desc = categoriasDescobertas(e.resultados, e.naoAplicaveis)
463  const est = estadoDe(e)
464  const linhas = e.resultados.map(r => {
465    const onde = `${r.arquivo}:${r.linha}${r.linhaReal && r.linhaReal !== r.linha ? ` (aplicado na ${r.linhaReal})` : ''}`
466    const sit =
467      r.situacao === 'MORTO'
468        ? `MORTO por "${r.motivo}"`
469        : r.situacao === 'SOBREVIVENTE'
470          ? 'SOBREVIVENTE → reforçar ou justificar como equivalente'
471          : `NÃO APLICADO: ${r.motivo}`
472    return `  ${r.n} | ${onde} | ${r.categoria} | ${r.mutacao} | ${sit}${r.escopo && r.situacao !== 'NÃO APLICADO' ? ` [${r.escopo}]` : ''}`
473  })
474  return [
475    `PROVA DE TESTE — ${e.tarefa} — ${data}  (executada pelo mod prova-de-teste)`,
476    `Red first:  a cargo do agente (o mod não sabe qual é a linha central)`,
477    `Escolha:    R2.1 às cegas por ${e.modelo} via $.model.complete; enviados: ${e.enviados.join(', ')}; testes retirados: ${e.excluidos.join(', ') || 'nenhum'}${e.naoCodigo?.length ? `; não-código fora do envio: ${e.naoCodigo.join(', ')}` : ''}`,
478    e.avisoNovos ?? '',
479    e.avisoCorte ?? '',
480    `Comando:    ${e.comando.join(' ; ')} — linha de base ${e.linhaDeBase.passou ? 'passou' : 'FALHOU'} (${e.linhaDeBase.segundos.toFixed(1)} s)`,
481    linhaTipos(e.tipos),
482    `Mutantes:   ${c.aplicados} aplicados | ${c.mortos} mortos | ${c.sobreviventes} sobreviventes${e.resultados.length > c.aplicados ? ` | ${e.resultados.length - c.aplicados} não aplicados` : ''}`,
483    ...linhas,
484    `Categorias: ${CATEGORIAS.map(k => `${k} ${desc.includes(k) ? '✘' : e.naoAplicaveis.some(n => n.startsWith(`${k}:`)) ? 'n/a' : '✔'}`).join(' | ')}`,
485    e.naoAplicaveis.length ? `n/a:        ${e.naoAplicaveis.join('; ')}` : '',
486    `Concentração: ${conc.maior}/${conc.total}${conc.alerta ? ` ⚠ concentração em "${conc.teste}": aplique mais 1 mutante fora dessa área` : ''}`,
487    `Restauração: ${e.restauracao}; original ${marca(e.originalIntocado)} ${e.originalIntocado ? 'intocado (nenhum mutante chegou a ele)' : 'ALTERADO: um mutante chegou ao arquivo original'}${e.mudouPorFora ? '; o repositório mudou por fora durante a rodada (commit ou edição de outra sessão): a cópia é de antes, a prova vale' : ''}`,
488    linhaParalelo(e.paralelo),
489    `Tempo:      ${e.segundos.toFixed(1)} s`,
490    `Custo:      ${resumoCusto(e)}`,
491    `Estado:     ${est}${est === 'FRACO' ? ' (sobrevivente sem tratamento ou categoria descoberta: a tarefa não é Done)' : ''}`,
492  ]
493    .filter(Boolean)
494    .join('\n')
495}
496
497/**
498 * O arquivo do diff a que o mutante se refere. O modelo às vezes devolve o caminho curto (`execucao.ts`):
499 * vale se for sufixo de exatamente um arquivo enviado, numa fronteira de pasta.
500 */
501export function resolverArquivo(arquivo: string, enviados: string[]): { arquivo: string } | { erro: string } {
502  if (!arquivo) return { erro: 'arquivo fora do diff enviado' }
503  // `execucao.ts:hooks/execucao.ts`: o caminho veio repetido com dois-pontos; vale se os pedaços resolvem a um arquivo só.
504  if (arquivo.includes(':') && !enviados.includes(arquivo)) {
505    const achados = new Set(arquivo.split(':').map(p => resolverArquivo(p, enviados)).flatMap(r => ('arquivo' in r ? [r.arquivo] : [])))
506    if (achados.size === 1) return { arquivo: [...achados][0]! }
507    return { erro: achados.size ? `caminho ambíguo: ${[...achados].join(', ')}` : 'arquivo fora do diff enviado' }
508  }
509  if (enviados.includes(arquivo)) return { arquivo }
510  // Curto (`execucao.ts`) ou com a pasta repetida na frente (`mod/mod/hooks/x.ts`), numa fronteira de pasta.
511  const candidatos = enviados.filter(e => e.endsWith(`/${arquivo}`) || arquivo.endsWith(`/${e}`))
512  if (candidatos.length === 1) return { arquivo: candidatos[0]! }
513  if (candidatos.length > 1) return { erro: `caminho ambíguo: ${candidatos.join(', ')}` }
514  return { erro: 'arquivo fora do diff enviado' }
515}
516
517/** Um caminho de arquivo tocado pelo diff que é teste não pode ser mutado: o mutante é sempre no código-fonte. */
518export const mutavel = (arquivo: string) => !ehArquivoDeTeste(arquivo)
519
520/** Divergência entre o que o agente quer registrar e o que o mod executou; nada quando batem. */
521export function conferir(
522  feita: { aplicados: number; mortos: number; sobreviventes: number; quando: string },
523  r: { aplicados?: number; mortos?: number; sobreviventes?: number },
524): string | undefined {
525  const iguais = feita.aplicados === r.aplicados && feita.mortos === r.mortos && feita.sobreviventes === r.sobreviventes
526  if (iguais) return undefined
527  return (
528    `os números não batem com a última execução do mod (${feita.aplicados}/${feita.mortos}/${feita.sobreviventes} em ${feita.quando}). ` +
529    'Registre os números executados; um reforço exige executar de novo.'
530  )
531}
532
533// Ao vivo (0.15.0): a rodada escreve o progresso num arquivo da sessão, para o painel do contexto-bar acompanhar
534// enquanto a ferramenta roda (nenhum hook da sessão dispara no meio dela). Contrato, versão 1:
535// ~/.claude/prova-de-teste/ao-vivo/<id da sessão, encodeURIComponent>.json = AoVivo. Quem lê ignora o que não conhece.
536export const VERSAO_AO_VIVO = 1
537export type FaseAoVivo = 'base' | 'mutantes' | 'fim'
538export type AoVivo = {
539  versao: 1
540  fonte: 'prova-de-teste'
541  tarefa: string
542  fase: FaseAoVivo
543  total: number
544  feitos: number
545  mortos: number
546  sobreviventes: number
547  naoAplicados: number
548  inicio: number
549  agora: number
550  copias?: number
551  estado?: string
552}
553
554/** O progresso da rodada: os números saem dos resultados já processados (os da fila ainda não rodados não contam). */
555export function progresso(
556  r: { tarefa: string; resultados: Resultado[]; feitos: number; total: number; copias?: number },
557  fase: FaseAoVivo,
558  inicio: number,
559  agora: number,
560  estado?: string,
561): AoVivo {
562  const c = contar(r.resultados)
563  return {
564    versao: VERSAO_AO_VIVO,
565    fonte: 'prova-de-teste',
566    tarefa: r.tarefa,
567    fase,
568    total: r.total,
569    feitos: r.feitos,
570    mortos: c.mortos,
571    sobreviventes: c.sobreviventes,
572    naoAplicados: fase === 'fim' ? r.resultados.length - c.aplicados : 0,
573    inicio,
574    agora,
575    ...(r.copias ? { copias: r.copias } : {}),
576    ...(estado ? { estado } : {}),
577  }
578}
579
hooks/placar.ts 135 lines
1// O log do EXP-001 lido com as mesmas regras do gerador do público (publico/gerar.py: classe, vigentes), para o
2// painel e o README não discordarem. Daqui sai a efetividade do ciclo, um indicador do contexto-bar.
3
4const LINHA = /^\d{4}-\d{2}-\d{2} \|/
5
6/** (instrumento, 'medição' | 'calibração') de uma linha v1.2; undefined nas v1.0/v1.1. */
7export function classe(linha: string): { instrumento: string; tipo: 'medição' | 'calibração' } | undefined {
8  const m = /\| instrumento: ([^|]+) \| (medição|calibração)/.exec(linha)
9  return m ? { instrumento: m[1]!.trim(), tipo: m[2] as 'medição' | 'calibração' } : undefined
10}
11
12/** As linhas que valem: uma CORREÇÃO da linha anterior substitui a linha classificada que veio antes dela. */
13export function vigentes(linhas: string[]): string[] {
14  const r: string[] = []
15  for (const l of linhas) {
16    if (l.includes('CORREÇÃO da linha anterior')) {
17      for (let i = r.length - 1; i >= 0; i--) {
18        if (classe(r[i]!)) {
19          r.splice(i, 1)
20          break
21        }
22      }
23    }
24    r.push(l)
25  }
26  return r
27}
28
29// Efetividade do ciclo (pedido do operador em 15/10/2026, no lugar do placar do EXP-001, que passou da meta):
30// - 1ª passada: dos mutantes da primeira lista às cegas de cada tarefa, quantos os testes já matam. Mede a qualidade
31//   do que o Claude escreve antes do adversário; não depende do tamanho do repositório, e quem escolhe os mutantes é
32//   outro modelo, sem ver os testes.
33// - até provar: o tempo de todas as rodadas de uma tarefa até o PROVADO. É o tempo do ciclo.
34// Os dois comparam as últimas JANELA tarefas provadas com as JANELA anteriores.
35export const JANELA = 5
36
37export type Tarefa = { nome: string; rodadas: number; aplicados1: number; mortos1: number; tempo: number; provada: boolean }
38
39/** O nome da tarefa: o que vem antes do primeiro ":" (contexto-bar 0.12.0, motor, publico). */
40const nomeDe = (l: string) => (l.split('|')[1] ?? '').split(':')[0]!.trim()
41
42/**
43 * As tarefas do log: as rodadas com o mesmo nome formam uma tarefa até o PROVADO, mesmo intercaladas com as de
44 * outra sessão que grava no mesmo log. Só linhas v1.2 com contagem de mutantes; a CORREÇÃO substitui a que corrige.
45 */
46export function tarefas(log: string): Tarefa[] {
47  const r: Tarefa[] = []
48  for (const l of vigentes(log.split('\n').filter(x => LINHA.test(x)))) {
49    const m = /\| mutantes: (\d+)\/(\d+)\/\d+ \|/.exec(l)
50    if (!m || !classe(l) || l.includes('CORREÇÃO da linha anterior')) continue
51    const campos = l.split('|').map(c => c.trim())
52    const tempo = /^\d+(\.\d+)?$/.test(campos[6] ?? '') ? Number(campos[6]) : 0
53    const nome = nomeDe(l)
54    const atual = r.findLast(t => t.nome === nome && !t.provada)
55    if (atual) {
56      atual.rodadas++
57      atual.tempo += tempo
58      atual.provada = campos[5] === 'PROVADO'
59    } else {
60      r.push({ nome, rodadas: 1, aplicados1: Number(m[1]), mortos1: Number(m[2]), tempo, provada: campos[5] === 'PROVADO' })
61    }
62  }
63  return r
64}
65
66export type Efetividade = {
67  n: number
68  primeira: number | null
69  primeiraAntes: number | null
70  tempo: number | null
71  tempoAntes: number | null
72  rodadas: number | null
73  rodadasAntes: number | null
74}
75
76function janela(ts: Tarefa[]): { primeira: number | null; tempo: number | null; rodadas: number | null } {
77  if (!ts.length) return { primeira: null, tempo: null, rodadas: null }
78  const apl = ts.reduce((a, t) => a + t.aplicados1, 0)
79  return {
80    primeira: apl ? ts.reduce((a, t) => a + t.mortos1, 0) / apl : null,
81    tempo: ts.reduce((a, t) => a + t.tempo, 0) / ts.length,
82    rodadas: ts.reduce((a, t) => a + t.rodadas, 0) / ts.length,
83  }
84}
85
86export function efetividade(log: string, n = JANELA): Efetividade {
87  const provadas = tarefas(log).filter(t => t.provada)
88  const agora = janela(provadas.slice(-n))
89  const antes = janela(provadas.slice(-2 * n, -n))
90  return { n: Math.min(n, provadas.length), primeira: agora.primeira, primeiraAntes: antes.primeira, tempo: agora.tempo, tempoAntes: antes.tempo, rodadas: agora.rodadas, rodadasAntes: antes.rodadas }
91}
92
93const pct = (x: number) => `${Math.round(x * 100)}%`
94const min = (s: number) => `${(s / 60).toFixed(1).replace('.', ',')} min`
95const dec = (x: number) => x.toFixed(1).replace('.', ',')
96
97/** O arquivo de indicadores (contrato do contexto-bar, versão 1, com o campo opcional `texto` para o centro). */
98export function indicadoresDeEfetividade(e: Efetividade): string {
99  const itens: Record<string, unknown>[] = []
100  if (e.primeira !== null) {
101    const melhor = e.primeiraAntes === null || e.primeira >= e.primeiraAntes
102    itens.push({
103      id: 'primeira-passada',
104      rotulo: '1ª passada',
105      valor: Math.round(e.primeira * 100),
106      meta: 100,
107      unidade: '%',
108      texto: pct(e.primeira),
109      estado: melhor ? 'ok' : 'atencao',
110      detalhe:
111        `mutantes que os testes já matam na 1ª lista às cegas, últimas ${e.n} tarefas: ${pct(e.primeira)}` +
112        `${e.primeiraAntes !== null ? ` (antes ${pct(e.primeiraAntes)})` : ''}` +
113        `${e.rodadas !== null ? ` · rodadas até provar ${dec(e.rodadas)}${e.rodadasAntes !== null ? ` (antes ${dec(e.rodadasAntes)})` : ''}` : ''}`,
114    })
115  }
116  if (e.tempo !== null) {
117    const base = e.tempoAntes
118    const delta = base ? Math.round(((e.tempo - base) / base) * 100) : null
119    itens.push({
120      id: 'ate-provar',
121      rotulo: 'até provar',
122      valor: Math.round(e.tempo),
123      // A meta é o tempo das tarefas anteriores: o arco cheio é gastar o mesmo; menos arco, ciclo mais curto.
124      ...(base ? { meta: Math.round(base) } : {}),
125      unidade: 's',
126      texto: min(e.tempo),
127      estado: base === null || e.tempo <= base ? 'ok' : 'atencao',
128      detalhe:
129        `tempo das rodadas até o PROVADO, média das últimas ${e.n} tarefas: ${min(e.tempo)}` +
130        `${base ? ` (antes ${min(base)}, ${delta! > 0 ? '+' : ''}${delta}%)` : ''}`,
131    })
132  }
133  return `${JSON.stringify({ versao: 1, fonte: 'prova-de-teste', itens }, null, 2)}\n`
134}
135
hooks/tipos.ts 78 lines
1/**
2 * A checagem de tipos da linha de base (0.16.0, R12). O kit de teste do mod roda o TypeScript sem tipar: a 0.19.1 do
3 * contexto-bar comparou `e.origin` (um objeto `{ kind }`) com 'person', o teste repetiu a suposição e a prova saiu
4 * PROVADO com o Esc quebrado na sessão real. O `tsc` contra os tipos do motor pegava (TS2345). Vale só o erro NOVO:
5 * o que o diff trouxe em relação à base; erro antigo do repositório não reprova a mudança de hoje.
6 */
7
8export type ErroDeTipo = { arquivo: string; linha?: number; codigo: string; mensagem: string }
9
10/** O resultado da checagem: as pastas checadas, os erros novos e quantos já existiam na base. */
11export type Tipos = { pastas: string[]; novos: ErroDeTipo[]; antigos: number; aviso?: string }
12
13/**
14 * Acha o compilador: o do repositório ($1), o do cache do npx ($2 = HOME) ou o do PATH. Imprime o caminho; nada se não há.
15 * O do npx é o que o `npx tsc` deixou; sem rede, é o único que existe nesta máquina (16/10/2026).
16 */
17export const ACHAR_TSC =
18  'for t in "$1"/node_modules/typescript/bin/tsc "$2"/.npm/_npx/*/node_modules/typescript/bin/tsc; do ' +
19  '[ -f "$t" ] && { printf "%s\\n" "$t"; exit 0; }; done; command -v tsc || true'
20
21/**
22 * Volta a cópia ($PWD) à ref $1: o índice passa a ser o da base, os arquivos saem do índice e o que a base não tinha
23 * sai (git clean sem -x: o ignorado, como o kit de tipos e o node_modules, fica). Só numa cópia com .git próprio.
24 */
25export const VOLTAR_A_BASE = 'git reset -q "$1" -- . && git checkout -q -f -- . && git clean -fdq -- .'
26
27/** As linhas `arquivo(l,c): error TSnnnn: mensagem` do `tsc --pretty false`; `pasta` prefixa o arquivo (relativo à raiz). */
28export function errosDeTipo(saida: string, pasta = ''): ErroDeTipo[] {
29  const erros: ErroDeTipo[] = []
30  for (const l of saida.split('\n')) {
31    const m = /^(.*?)\((\d+),\d+\): error (TS\d+): (.*)$/.exec(l) ?? /^()error (TS\d+): (.*)$/.exec(l)
32    if (!m) continue
33    const comArquivo = m.length === 5
34    const arquivo = m[1] ? `${pasta ? `${pasta}/` : ''}${m[1]}` : ''
35    erros.push({ arquivo, ...(comArquivo ? { linha: Number(m[2]) } : {}), codigo: comArquivo ? m[3]! : m[2]!, mensagem: (comArquivo ? m[4]! : m[3]!).trim() })
36  }
37  return erros
38}
39
40/** A chave do erro sem a linha: o diff desloca as linhas, e o erro antigo continua sendo o mesmo. */
41const chave = (e: ErroDeTipo) => `${e.arquivo}\0${e.codigo}\0${e.mensagem}`
42
43/** Os erros de `agora` que a base não tinha, contando repetições (dois iguais onde havia um: um é novo). */
44export function errosNovos(base: ErroDeTipo[], agora: ErroDeTipo[]): ErroDeTipo[] {
45  const restam = new Map<string, number>()
46  for (const e of base) restam.set(chave(e), (restam.get(chave(e)) ?? 0) + 1)
47  return agora.filter(e => {
48    const n = restam.get(chave(e)) ?? 0
49    if (n > 0) restam.set(chave(e), n - 1)
50    return n === 0
51  })
52}
53
54/** As pastas a checar: as dos comandos (o plugin; '' = raiz) que têm tsconfig.json e algum .ts/.tsx tocado. */
55export function pastasParaTipos(pastas: string[], comTsconfig: (pasta: string) => boolean, tocados: string[]): string[] {
56  const ts = tocados.filter(t => /\.(c|m)?tsx?$/.test(t))
57  return [...new Set(pastas)].filter(p => comTsconfig(p) && ts.some(t => p === '' || t.startsWith(`${p}/`)))
58}
59
60/** As linhas do relatório; nada quando não houve pasta a checar. */
61export function linhaTipos(t: Tipos | undefined): string {
62  if (!t) return ''
63  const onde = t.pastas.map(p => p || '.').join(', ')
64  const antigos = t.antigos ? `; ${t.antigos} já existia${t.antigos > 1 ? 'm' : ''} na base (não reprova)` : ''
65  const n = t.novos.length
66  const linhas =
67    t.aviso && !n
68      ? []
69      : n
70        ? [
71            `Tipos:      tsc em ${onde}: ${n} erro${n > 1 ? 's' : ''} novo${n > 1 ? 's' : ''} → a linha de base reprova${antigos}`,
72            ...t.novos.slice(0, 10).map(e => `  ${e.arquivo || '(projeto)'}${e.linha ? `:${e.linha}` : ''} ${e.codigo}: ${e.mensagem}`),
73            ...(n > 10 ? [`  … e mais ${n - 10}`] : []),
74          ]
75        : [`Tipos:      tsc em ${onde}: nenhum erro novo${antigos}`]
76  return [...linhas, ...(t.aviso ? [`${n ? '  ' : 'Tipos:      '}⚠ ${t.aviso}`] : [])].join('\n')
77}
78
hooks/prova.ts 280 lines
1import type { Categoria, Mutante } from '../types'
2
3export const VERSAO = '1.2'
4/** O protocolo viaja com o instrumento: PROTOCOLO.md na pasta do mod. */
5export const protocoloEm = (pastaDoMod: string) => `${pastaDoMod}/PROTOCOLO.md`
6/** O log: PROVA_DE_TESTE_LOG, ou este caminho padrão (que pode ser um link para onde o log já vive). */
7export const LOG_ENV = 'PROVA_DE_TESTE_LOG'
8export const logPadrao = (home: string) => `${home}/.claude/prova-de-teste/EXP-001-log.md`
9/** O log local de um projeto que não é o do log compartilhado: fora de qualquer git, sem commit (15/10/2026). */
10export const logDoProjeto = (home: string, projeto: string) => `${home}/.claude/prova-de-teste/projetos/${encodeURIComponent(projeto || 'sem-projeto')}.md`
11export const PASTA_PROJETOS = (home: string) => `${home}/.claude/prova-de-teste/projetos`
12export const CABECALHO_LOG =
13  '# EXP-001 — log\n\n' +
14  `v${VERSAO}: \`<data> | <tarefa> | versão do protocolo | mutantes: aplicados/mortos/sobreviventes | reais: <n> | estado | tempo (s) | instrumento: <mod x.y.z | manual> | <medição | calibração: defeito> [| custo: base, por mutante, repositório, diff]\`\n\n`
15
16export const CATEGORIAS: Exclude<Categoria, 'livre'>[] = ['borda/vazio', 'empate/ordem', 'null/inválido', 'condição invertida']
17
18// Arquivos de teste pelas convenções comuns de JS/TS, Python, Go e Rust.
19const TESTE = [
20  /(^|\/)(tests?|__tests__|spec|specs)\//,
21  /\.(test|spec)\.[cm]?[jt]sx?$/,
22  /(^|\/)test_[^/]+\.py$/,
23  /_test\.(py|go|rs)$/,
24]
25
26// Dentro da pasta de teste, helpers/support/utils são código que os testes usam (e podem ser a correção da tarefa);
27// o nome de teste (.test., test_*.py...) ainda vale. Fixtures ficam como teste: trazem os valores esperados.
28const HELPER = /(^|\/)(tests?|__tests__|spec|specs)\/(.+\/)?(helpers?|support|utils?)\//
29
30export function ehArquivoDeTeste(caminho: string): boolean {
31  const [pasta, ...nome] = TESTE
32  if (nome.some(r => r.test(caminho))) return true
33  return pasta!.test(caminho) && !HELPER.test(caminho)
34}
35
36// O que não é código não vai ao escolhedor: documentação, dotfiles de ferramenta, lockfiles, imagens.
37const NAO_CODIGO = [
38  /\.(md|mdx|markdown|txt|rst|adoc|lock|png|jpe?g|gif|webp|ico|svg|pdf)$/i,
39  /(^|\/)\.(gitignore|gitattributes|editorconfig|npmrc|nvmrc|prettierrc|prettierignore|eslintignore|dockerignore)$/,
40  /(^|\/)(package-lock\.json|pnpm-lock\.yaml|yarn\.lock|LICENSE|CHANGELOG)$/,
41  // Bytecode e cache do Python: os .pyc apagados do índice entravam no envio como código (rodada no publico, 0.8.2).
42  /(^|\/)__pycache__\//,
43  /\.py[co]$/,
44]
45export const ehNaoCodigo = (caminho: string) => NAO_CODIGO.some(r => r.test(caminho))
46
47/** Bloco binário do git diff: não tem linha de texto que o escolhedor possa mutar. */
48const ehBinario = (texto: string) => /^(Binary files .* differ|GIT binary patch)$/m.test(texto)
49
50/** Aviso quando a maior parte do envio é de arquivos fora do git: pode ser código antigo nunca commitado, não a tarefa. */
51export function avisoNovos(enviados: string[], novos: string[]): string {
52  const fora = enviados.filter(f => novos.includes(f))
53  if (fora.length * 2 <= enviados.length) return ''
54  return `⚠ ${fora.length} de ${enviados.length} arquivos enviados estão fora do git (${fora.join(', ')}). Se forem código anterior à tarefa, commite-os ou rode com novos: false.`
55}
56
57/** Argumentos do /prova: o que parece caminho é a pasta do repositório; o resto é a base do diff. Repetido, vale o último. */
58export function lerArgs(args: string, home: string): { base?: string; cwd?: string } {
59  const r: { base?: string; cwd?: string } = {}
60  for (const t of args.trim().split(/\s+/).filter(Boolean)) {
61    // Sem HOME conhecido, o ~ fica como veio: melhor um erro claro do git que uma pasta trocada em silêncio.
62    if (t === '~' || t.startsWith('~/')) r.cwd = home ? home + t.slice(1) : t
63    else if (t.startsWith('/') || t.startsWith('./') || t.startsWith('../')) r.cwd = t
64    else r.base = t
65  }
66  return r
67}
68
69export type BlocoDiff = { arquivo: string; texto: string }
70
71/** Separa um `git diff` em blocos por arquivo. */
72export function blocos(diff: string): BlocoDiff[] {
73  const partes = diff.split(/^(?=diff --git )/m).filter(p => p.startsWith('diff --git '))
74  return partes.map(texto => {
75    // Prefere o caminho novo (+++ b/x); em arquivo apagado, o antigo.
76    const novo = /^\+\+\+ b\/(.+)$/m.exec(texto)?.[1]
77    const antigo = /^--- a\/(.+)$/m.exec(texto)?.[1]
78    const cabecalho = /^diff --git a\/(.+?) b\/(.+)$/m.exec(texto)
79    return { arquivo: novo ?? antigo ?? cabecalho?.[2] ?? '?', texto }
80  })
81}
82
83/** O diff que vai ao modelo: só código-fonte; os testes e o que não é código saem e são listados à parte. */
84export function separar(diff: string): { fonte: BlocoDiff[]; excluidos: string[]; naoCodigo: string[] } {
85  const todos = blocos(diff)
86  const testes = todos.filter(b => ehArquivoDeTeste(b.arquivo))
87  const naoCodigo = todos.filter(b => !ehArquivoDeTeste(b.arquivo) && (ehNaoCodigo(b.arquivo) || ehBinario(b.texto)))
88  return {
89    fonte: todos.filter(b => !testes.includes(b) && !naoCodigo.includes(b)),
90    excluidos: testes.map(b => b.arquivo),
91    naoCodigo: naoCodigo.map(b => b.arquivo),
92  }
93}
94
95export const SISTEMA = `Você escolhe mutantes para um teste de mutação manual (protocolo EXP-001 v${VERSAO}).
96Você recebe SÓ o diff do código-fonte de uma tarefa. Você NÃO vê os testes, de propósito: escolha os bugs plausíveis que um teste precisaria pegar, não os que você imagina que ele pega.
97
98Regras:
99- Pelo menos 1 mutante por categoria que se aplique ao código alterado: ${CATEGORIAS.join(', ')}.
100  - borda/vazio: lista vazia, tudo filtrado, limite < → <=, off-by-one.
101  - empate/ordem: remover o desempate, inverter a ordem, ordenação não estável.
102  - null/inválido: null ou undefined não cai no padrão; valor fora do domínio aceito.
103  - condição invertida: if (x) → if (!x), filtro invertido.
104- Pode somar mutantes da categoria "livre" (remover uma chamada, trocar uma string por ""), mas eles não substituem as categorias.
105- Use o caminho COMPLETO do arquivo, exatamente como no cabeçalho \`+++ b/<caminho>\` do diff.
106- Mute só linhas adicionadas ou alteradas no diff (linhas com +). Use o número da linha no arquivo NOVO, contado pelo cabeçalho @@ do hunk.
107- Cada mutação descreve a troca exata: \`trecho original\` → \`trecho mutado\`.
108
109Responda SÓ com linhas neste formato, uma por mutante, sem texto antes ou depois:
110arquivo:linha | categoria | mutação
111Para cada categoria que não se aplica, uma linha:
112n/a | categoria | motivo`
113
114export function montarPrompt(fonte: BlocoDiff[]): string {
115  return `Diff do código-fonte (${fonte.length} arquivo(s)):\n\n${fonte.map(b => b.texto).join('\n')}`
116}
117
118const ehCategoria = (c: string): c is Categoria => (CATEGORIAS as string[]).includes(c) || c === 'livre'
119
120/** Lê a resposta do modelo; linhas fora do formato são devolvidas à parte, para ninguém as perder de vista. */
121export function lerResposta(texto: string): { mutantes: Mutante[]; naoAplicaveis: string[]; ignoradas: string[] } {
122  const mutantes: Mutante[] = []
123  const naoAplicaveis: string[] = []
124  const ignoradas: string[] = []
125  for (const bruta of texto.split('\n')) {
126    const linha = bruta.trim().replace(/^[-*]\s+/, '')
127    if (!linha) continue
128    // Só os dois primeiros | separam campos: a mutação pode conter | (como em `||`).
129    const [onde, cat, ...cauda] = linha.split('|')
130    const resto = cauda.join('|').trim()
131    const categoria = (cat ?? '').trim().toLowerCase()
132    if (onde?.trim() === 'n/a' && ehCategoria(categoria) && resto) {
133      naoAplicaveis.push(`${categoria}: ${resto}`)
134      continue
135    }
136    const m = /^(.+):(\d+)$/.exec((onde ?? '').trim())
137    if (!m || !ehCategoria(categoria) || !resto) {
138      ignoradas.push(linha)
139      continue
140    }
141    mutantes.push({ arquivo: m[1]!, linha: Number(m[2]), categoria, mutacao: resto })
142  }
143  return { mutantes, naoAplicaveis, ignoradas }
144}
145
146/** Categorias aplicáveis que ficaram sem mutante e sem justificativa. */
147export function lacunas(mutantes: Mutante[], naoAplicaveis: string[]): string[] {
148  return CATEGORIAS.filter(c => !mutantes.some(m => m.categoria === c) && !naoAplicaveis.some(n => n.startsWith(`${c}:`)))
149}
150
151export function tabela(mutantes: Mutante[]): string {
152  return mutantes.map((m, i) => `  ${i + 1} | ${m.arquivo}:${m.linha} | ${m.categoria} | ${m.mutacao}`).join('\n')
153}
154
155export type Registro = {
156  tarefa: string
157  aplicados: number
158  mortos: number
159  sobreviventes: number
160  reais: number
161  estado: 'PROVADO' | 'PROVADO-PARCIAL' | 'FRACO' | 'NÃO VERIFICADO'
162  tempo: number
163  /** v1.2: o que executou a prova (`mod x.y.z` ou `manual v1.2`). */
164  instrumento?: string
165  /** v1.2: defeitos do instrumento nesta rodada; algum faz dela calibração, nenhum faz dela medição. */
166  defeitos?: string[]
167  /** Sobreviventes alegados como equivalentes, com o motivo: vão à linha do log, para quem audita. */
168  equivalentes?: { n: number; motivo: string }[]
169  /** 0.7.0: o custo medido pelo mod na execução (nunca informado pelo agente). */
170  custo?: string
171  /** 0.14.0: mutantes que o agente aplicou à mão, fora da lista; vão à linha, sem entrar em aplicados. */
172  manuais?: Manual[]
173}
174
175/**
176 * 0.14.0: um mutante aplicado à mão. Existe para a categoria que o escolhedor só descreve em prosa (mover uma linha,
177 * trocar a ordem de duas chamadas) e que não cabe num trecho de substituição. O mod não o executou: é declarado,
178 * fica auditável na linha do log e só cobre categoria descoberta, nunca troca os números da execução.
179 */
180export type Manual = { arquivo: string; linha?: number; categoria: string; mutacao: string; mortoPor: string }
181
182/** O erro do primeiro mutante manual malformado; nada quando todos valem. */
183export function validarManuais(ms: Manual[] | undefined): string | undefined {
184  for (const [i, m] of (ms ?? []).entries()) {
185    const n = `mutante manual #${i + 1}`
186    if (!m?.arquivo?.trim()) return `${n}: falta arquivo.`
187    if (m.linha !== undefined && (!Number.isInteger(m.linha) || m.linha < 1)) return `${n}: linha precisa ser um inteiro ≥ 1.`
188    if (!ehCategoria(m.categoria)) return `${n}: categoria precisa ser uma de: ${[...CATEGORIAS, 'livre'].join(', ')}.`
189    if (!m.mutacao?.trim()) return `${n}: falta mutacao.`
190    if (!m.mortoPor?.trim()) return `${n}: falta mortoPor (o teste que falhou com ele; sobrevivente não se declara à mão).`
191  }
192  return undefined
193}
194
195const manualNaLinha = (m: Manual) =>
196  `${m.arquivo.trim()}${m.linha ? `:${m.linha}` : ''} ${m.categoria} ${m.mutacao.trim()} → morto por "${m.mortoPor.trim()}"`
197
198/**
199 * v1.2: por que esta rodada calibra o instrumento em vez de medir o método. Vazio = medição.
200 * O defeito informado pelo agente, o diff que mexe no próprio mod, e a versão que mudou entre executar e registrar.
201 */
202export function defeitosDoInstrumento(d: { informado?: string; alteraInstrumento?: boolean; versaoNaExecucao?: string; versaoAgora?: string }): string[] {
203  const r: string[] = []
204  if (d.informado?.trim()) r.push(d.informado.trim())
205  if (d.alteraInstrumento) r.push('o diff altera o próprio instrumento')
206  if (d.versaoNaExecucao && d.versaoAgora && d.versaoNaExecucao !== d.versaoAgora) r.push(`instrumento alterado na rodada: ${d.versaoNaExecucao}→${d.versaoAgora}`)
207  return r
208}
209
210export const ESTADOS: Registro['estado'][] = ['PROVADO', 'PROVADO-PARCIAL', 'FRACO', 'NÃO VERIFICADO']
211
212/** A versão em plugin.json; ilegível ou ausente, '?'. */
213export function versaoDe(pluginJson: string): string {
214  try {
215    return String((JSON.parse(pluginJson) as { version?: unknown }).version ?? '?')
216  } catch {
217    return '?'
218  }
219}
220
221/** O instrumento da linha: o mod na versão que executou; sem execução do mod, a rota manual (undefined). */
222export const instrumentoDe = (feita: { versao?: string } | null | undefined) => (feita ? `mod ${feita.versao ?? '?'}` : undefined)
223
224/** O nome no plugin.json; ilegível, nada. */
225export function nomeDe(pluginJson: string): string | undefined {
226  try {
227    const nome = (JSON.parse(pluginJson) as { name?: unknown }).name
228    return typeof nome === 'string' && nome ? nome : undefined
229  } catch {
230    return undefined
231  }
232}
233
234/**
235 * O diff mexe no próprio mod: arquivos sob a pasta dele, numa fronteira de pasta, ou sob um plugin com o nome dele,
236 * onde quer que esteja (um clone do repositório dos mods em /tmp entrava como medição, 0.8.3).
237 */
238export const tocaOInstrumento = (raiz: string, enviados: string[], pastaDoMod: string, pluginsTocados: string[] = [], nomeDoMod = 'prova-de-teste') =>
239  enviados.some(f => `${raiz}/${f}`.startsWith(`${pastaDoMod}/`)) || pluginsTocados.includes(nomeDoMod)
240
241/** Valida o registro; devolve o erro ou a linha no formato v1.2 do log. */
242export function linhaLog(r: Partial<Registro>, data: string): { linha?: string; erro?: string } {
243  if (!r.tarefa?.trim()) return { erro: 'falta tarefa.' }
244  const n = ['aplicados', 'mortos', 'sobreviventes', 'reais', 'tempo'] as const
245  for (const k of n) {
246    const v = r[k]
247    if (typeof v !== 'number' || !Number.isFinite(v) || v < 0) return { erro: `${k} precisa ser um número ≥ 0.` }
248  }
249  if (r.mortos! + r.sobreviventes! !== r.aplicados) return { erro: 'mortos + sobreviventes precisa dar aplicados.' }
250  if (!ESTADOS.includes(r.estado as Registro['estado'])) return { erro: `estado precisa ser um de: ${ESTADOS.join(', ')}.` }
251  const manualRuim = validarManuais(r.manuais)
252  if (manualRuim) return { erro: manualRuim }
253  const t = r.tarefa.trim().replace(/\s*\|\s*/g, ' / ')
254  return {
255    linha:
256      `${data} | ${t} | v${VERSAO} | mutantes: ${r.aplicados}/${r.mortos}/${r.sobreviventes} | reais: ${r.reais} | ${r.estado} | ${r.tempo}` +
257      ` | instrumento: ${r.instrumento ?? `manual v${VERSAO}`} | ${r.defeitos?.length ? `calibração: ${r.defeitos.join('; ').replace(/\s*\|\s*/g, ' / ')}` : 'medição'}` +
258      (r.equivalentes?.length ? ` | equivalentes: ${r.equivalentes.map(q => `#${q.n} ${q.motivo.trim()}`).join('; ').replace(/\s*\|\s*/g, ' / ')}` : '') +
259      (r.manuais?.length ? ` | manuais: ${r.manuais.map(manualNaLinha).join('; ').replace(/\s*\|\s*/g, ' / ')}` : '') +
260      (r.custo ? ` | custo: ${r.custo.replace(/\s*\|\s*/g, ' / ')}` : ''),
261  }
262}
263
264/** Teto de tokens da resposta do escolhedor. Com 4000, a resposta era cortada antes da segunda categoria (0.8.4). */
265export const LIMITE_ESCOLHA = 16000
266
267/**
268 * Resposta que bateu no teto: a última linha pode estar pela metade e sai (não vira mutante malformado),
269 * e o aviso diz que pode faltar categoria.
270 */
271export function cortarResposta(texto: string, saida: number, limite: number): { texto: string; aviso?: string } {
272  if (saida < limite) return { texto }
273  const linhas = texto.replace(/\s+$/, '').split('\n')
274  linhas.pop()
275  return {
276    texto: linhas.join('\n'),
277    aviso: `⚠ Resposta do escolhedor cortada no teto de ${limite} tokens: a última linha saiu (podia estar pela metade) e pode faltar categoria. Acrescente mutantes nas categorias que faltarem.`,
278  }
279}
280
hooks/alcance.ts 185 lines
1/**
2 * Execução incremental: que arquivos de teste alcançam o arquivo mutado, pelo grafo de imports.
3 * O mapa só decide a velocidade, nunca o veredito: quem usa isto confirma sobrevivente na suíte inteira.
4 */
5
6const JS = /\.(?:[cm]?[jt]sx?)$/
7const PY = /\.py$/
8const EXTENSOES_JS = ['.ts', '.tsx', '.js', '.jsx', '.mjs', '.cjs', '.mts', '.cts']
9
10/** O arquivo de teste que o executor descobre pelo nome; helpers e fixtures da pasta de teste ficam de fora. */
11export function ehEntradaDeTeste(caminho: string): boolean {
12  return /\.(test|spec)\.[cm]?[jt]sx?$/.test(caminho) || /(^|\/)test_[^/]+\.py$/.test(caminho) || /(^|\/)[^/]+_test\.py$/.test(caminho)
13}
14
15export const ehMapeavel = (caminho: string) => JS.test(caminho) || PY.test(caminho)
16
17const pasta = (p: string) => p.slice(0, p.lastIndexOf('/') + 1)
18
19/** `a/b/../c/./d` → `a/c/d`; subir acima da raiz devolve nada. */
20export function normalizar(p: string): string | undefined {
21  const r: string[] = []
22  for (const s of p.split('/')) {
23    if (s === '' || s === '.') continue
24    if (s === '..') {
25      if (!r.length) return undefined
26      r.pop()
27    } else r.push(s)
28  }
29  return r.join('/')
30}
31
32/** Os especificadores de um arquivo JS/TS: import, export from, require e import(). Pacotes entram e não resolvem. */
33export function especificadoresJs(fonte: string): string[] {
34  const r = new Set<string>()
35  const padroes = [
36    /\b(?:import|export)\s[^'"`;]*?\sfrom\s*['"]([^'"]+)['"]/g,
37    /\bimport\s*['"]([^'"]+)['"]/g,
38    /\b(?:require|import)\s*\(\s*['"]([^'"]+)['"]\s*\)/g,
39  ]
40  for (const p of padroes) for (const m of fonte.matchAll(p)) r.add(m[1]!)
41  return [...r]
42}
43
44/**
45 * Um alias de import, válido para os arquivos sob `escopo` (a pasta do tsconfig ou do vite.config; '' = a raiz).
46 * Com `curinga` (`@/*`), `de` é prefixo e o resto do especificador vai depois de `para`; sem, só vale o nome exato.
47 */
48export type Alias = { escopo: string; de: string; para: string; curinga: boolean }
49
50/** Os aliases dos `paths` de um tsconfig, relativos ao baseUrl (ou à pasta dele). Lidos por regex: tsconfig aceita comentário. */
51export function aliasesDeTsconfig(caminho: string, texto: string): Alias[] {
52  const dir = pasta(caminho)
53  const bloco = /"paths"\s*:\s*\{([^}]*)\}/.exec(texto)?.[1]
54  if (!bloco) return []
55  const baseUrl = /"baseUrl"\s*:\s*"([^"]*)"/.exec(texto)?.[1] ?? '.'
56  const raiz = normalizar(dir + baseUrl)
57  if (raiz === undefined) return []
58  const r: Alias[] = []
59  for (const m of bloco.matchAll(/"([^"]+)"\s*:\s*\[\s*"([^"]+)"/g)) {
60    const [chave, alvo] = [m[1]!, m[2]!]
61    const curinga = chave.endsWith('*') && alvo.endsWith('*')
62    const prefixo = curinga ? alvo.slice(0, -1) : alvo
63    const para = normalizar(`${raiz}/${prefixo}`)
64    if (para === undefined) continue
65    // `src/*` e `*` substituem uma pasta (a barra volta depois de normalizar); `lib*` é prefixo de nome, sem barra.
66    const pasta_ = curinga && (prefixo === '' || prefixo.endsWith('/'))
67    r.push({ escopo: dir, de: curinga ? chave.slice(0, -1) : chave, para: pasta_ && para ? `${para}/` : para, curinga })
68  }
69  return r
70}
71
72/** Os aliases de um vite.config/vitest.config na forma `'@': path.resolve(__dirname, 'src')`. */
73export function aliasesDeVite(caminho: string, texto: string): Alias[] {
74  const dir = pasta(caminho)
75  const r: Alias[] = []
76  for (const m of texto.matchAll(/['"]([^'"]+)['"]\s*:\s*path\.resolve\(\s*__dirname\s*,\s*['"]([^'"]+)['"]\s*\)/g)) {
77    const para = normalizar(dir + m[2]!)
78    if (para === undefined) continue
79    // `@` vale como `@` exato e como prefixo `@/`.
80    r.push({ escopo: dir, de: m[1]!, para, curinga: false }, { escopo: dir, de: `${m[1]!}/`, para: para ? `${para}/` : '', curinga: true })
81  }
82  return r
83}
84
85export const ehConfigDeAlias = (caminho: string) => /(^|\/)(tsconfig[^/]*\.json|vite(st)?\.config\.[cm]?[jt]s)$/.test(caminho)
86
87/** Os aliases de um arquivo de configuração, pelo nome. */
88export const aliasesDe = (caminho: string, texto: string) => (caminho.endsWith('.json') ? aliasesDeTsconfig(caminho, texto) : aliasesDeVite(caminho, texto))
89
90/** Onde um especificador com alias aponta, pelos aliases que valem para `de`: o escopo mais fundo vence. */
91function porAlias(de: string, spec: string, aliases: Alias[]): string[] {
92  const valem = aliases.filter(a => de.startsWith(a.escopo) && (a.curinga ? spec.startsWith(a.de) : spec === a.de))
93  if (!valem.length) return []
94  const fundo = Math.max(...valem.map(a => a.escopo.length))
95  return valem.filter(a => a.escopo.length === fundo).map(a => a.para + spec.slice(a.de.length))
96}
97
98/** O arquivo do repositório que um especificador nomeia (relativo ou por alias); `./x.js` também acha `x.ts` (ESM com TypeScript). */
99export function resolverJs(de: string, spec: string, arquivos: Set<string>, aliases: Alias[] = []): string | undefined {
100  if (!spec.startsWith('.')) {
101    for (const alvo of porAlias(de, spec, aliases)) {
102      const achado = resolverJs('', `./${alvo}`, arquivos)
103      if (achado) return achado
104    }
105    return undefined
106  }
107  const base = normalizar(pasta(de) + spec)
108  if (base === undefined) return undefined
109  const semJs = base.replace(/\.[cm]?jsx?$/, '')
110  const candidatos = [base, ...EXTENSOES_JS.map(x => semJs + x), ...EXTENSOES_JS.map(x => `${base}/index${x}`)]
111  return candidatos.find(c => arquivos.has(c))
112}
113
114/** Os módulos que um arquivo Python importa, como caminhos candidatos (relativos e absolutos). */
115export function importsPy(de: string, fonte: string, arquivos: Set<string>): string[] {
116  const r = new Set<string>()
117  const modulo = (pontos: string, nome: string): string[] => {
118    const partes = nome ? nome.split('.') : []
119    if (pontos) {
120      // `from .. import x`: um ponto é a pasta do arquivo; cada ponto a mais sobe uma.
121      let dir = pasta(de).replace(/\/$/, '')
122      for (let i = 1; i < pontos.length; i++) dir = dir.includes('/') ? dir.slice(0, dir.lastIndexOf('/')) : ''
123      return [[dir, ...partes].filter(Boolean).join('/')]
124    }
125    // Absoluto: a partir da raiz, de src/ e da pasta do arquivo.
126    return ['', 'src/', pasta(de)].map(p => p + partes.join('/'))
127  }
128  const achar = (m: string) => [`${m}.py`, `${m}/__init__.py`].find(c => arquivos.has(c))
129  for (const l of fonte.split('\n')) {
130    const de_ = /^\s*from\s+(\.*)([\w.]*)\s+import\s+\(?([\w\s,*]+)/.exec(l)
131    if (de_) {
132      for (const m of modulo(de_[1]!, de_[2]!)) {
133        const a = achar(m)
134        if (a) r.add(a)
135        // `from pacote import modulo`: o nome importado pode ser um submódulo.
136        for (const nome of de_[3]!.split(',').map(s => s.trim().split(/\s+/)[0]!).filter(n => n && n !== '*')) {
137          const sub = achar(m ? `${m}/${nome}` : nome)
138          if (sub) r.add(sub)
139        }
140      }
141      continue
142    }
143    const imp = /^\s*import\s+([\w.,\s]+)/.exec(l)
144    if (imp)
145      for (const nome of imp[1]!.split(',').map(s => s.trim().split(/\s+/)[0]!).filter(Boolean)) {
146        // `import a.b.c` carrega a, a.b e a.b.c.
147        const partes = nome.split('.')
148        for (let i = 1; i <= partes.length; i++)
149          for (const m of modulo('', partes.slice(0, i).join('.'))) {
150            const a = achar(m)
151            if (a) r.add(a)
152          }
153      }
154  }
155  return [...r]
156}
157
158/** O que cada arquivo importa, dentro do repositório. */
159export function grafo(fontes: Map<string, string>, aliases: Alias[] = []): Map<string, string[]> {
160  const arquivos = new Set(fontes.keys())
161  const g = new Map<string, string[]>()
162  for (const [arq, fonte] of fontes) {
163    if (JS.test(arq)) g.set(arq, especificadoresJs(fonte).flatMap(s => resolverJs(arq, s, arquivos, aliases) ?? []))
164    else if (PY.test(arq)) g.set(arq, importsPy(arq, fonte, arquivos))
165  }
166  return g
167}
168
169/** Os arquivos de teste que chegam ao alvo seguindo imports, em qualquer profundidade; o próprio alvo, se for teste. */
170export function testesQueAlcancam(alvo: string, g: Map<string, string[]>): string[] {
171  // Grafo reverso: quem importa cada arquivo.
172  const quem = new Map<string, string[]>()
173  for (const [de, para] of g) for (const p of para) quem.set(p, [...(quem.get(p) ?? []), de])
174  const vistos = new Set([alvo])
175  const fila = [alvo]
176  while (fila.length) for (const q of quem.get(fila.shift()!) ?? []) if (!vistos.has(q)) (vistos.add(q), fila.push(q))
177  return [...vistos].filter(ehEntradaDeTeste).sort()
178}
179
180export const SUFIXO_FORA = '.prova-fora'
181
182/** Acima disso, ler cada arquivo para o mapa custaria mais do que poupa. */
183export const LIMITE_MAPA = 20_000
184export const cabeNoMapa = (arquivos: number) => arquivos <= LIMITE_MAPA
185
types/index.d.ts 55 lines
1export type Categoria = 'borda/vazio' | 'empate/ordem' | 'null/inválido' | 'condição invertida' | 'livre'
2
3export type Mutante = { arquivo: string; linha: number; categoria: Categoria; mutacao: string }
4
5/** A última lista escolhida às cegas: o que foi enviado e o que voltou. */
6export type Escolha = {
7  /** Raiz do repositório de onde saiu o diff. */
8  raiz: string
9  base: string
10  modelo: string
11  /** Arquivos-fonte cujo diff foi enviado ao modelo. */
12  enviados: string[]
13  /** Arquivos de teste retirados do diff antes do envio. */
14  excluidos: string[]
15  /** Arquivos que não são código (docs, dotfiles, lockfiles), fora do envio. */
16  naoCodigo?: string[]
17  /** Aviso de envio dominado por arquivos fora do git. */
18  avisoNovos?: string
19  /** 0.9.0: a resposta bateu no teto de tokens e a última linha saiu. */
20  avisoCorte?: string
21  mutantes: Mutante[]
22  /** 0.7.0: linhas adicionadas ou removidas no diff do código-fonte enviado. */
23  linhasDoDiff?: number
24  /** Categorias que o modelo declarou não aplicáveis, com o motivo. */
25  naoAplicaveis: string[]
26}
27
28/** Os números da última rodada que o próprio mod executou: o registrar só aceita estes. */
29export type ExecucaoFeita = {
30  tarefa: string
31  /** 0.11.0: a raiz do repositório da rodada, que decide se a linha vai ao log compartilhado ou ao do projeto. */
32  raiz?: string
33  aplicados: number
34  mortos: number
35  sobreviventes: number
36  estado: string
37  quando: string
38  /** v1.2: versão do mod que executou, e se o diff mexia no próprio mod. */
39  versao?: string
40  alteraInstrumento?: boolean
41  /** 0.6.0: o teto do estado com os sobreviventes justificados, e quais sobreviveram. */
42  estadoSeEquivalentes?: string
43  sobreviventesN?: number[]
44  /** 0.14.0: as categorias sem mutante aplicado, que um mutante manual morto pode cobrir. */
45  descobertas?: string[]
46  /** 0.7.0: o resumo do custo da rodada (base, por mutante, repositório, diff), que vai ao log. */
47  custo?: string
48}
49
50declare module 'claude-code' {
51  interface PluginState {
52    'prova-de-teste': { avisadoTurno: number; ultima: Escolha | null; execucao: ExecucaoFeita | null }
53  }
54}
55