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…

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.
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.
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.
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.
Pedi ao agente que construiu e usou os mods uma avaliação do próprio trabalho, sem enfeite. Os números saem do log.
registrar, não na memória do agente. Um estado melhor que o executado é recusado.claude plugin marketplace add aiob3/claude-grounds
claude plugin install contexto-bar@claude-grounds
claude plugin install prova-de-teste@claude-grounds
| Mod | Versão | Papel |
|---|---|---|
contexto-bar | 0.21.0 | faixa de contexto, objetivo por projeto, painel e projeção da janela |
prova-de-teste | 0.16.0 | rodada 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ê digita | O que acontece |
|---|---|
/objetivo | abre o painel |
/objetivo Migrar o login :: testes verdes em produção | define o objetivo e o passo que o encerra |
/objetivo feito 2 3 | conclui as pendências 2 e 3 |
/objetivo bloqueante 4 | sobe a pendência 4 para o topo da fila |
/objetivo meu revisar o contrato | acrescenta uma pendência sua |
/objetivo encerrar | encerra o objetivo do projeto |
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 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.
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.
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.
<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.
<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.
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]
}
}
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.
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:
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.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 | |
|---|---|---|
| Instalar | editar o arquivo e escrever os scripts | claude plugin install |
| O agente pode chamar | nada | objetivo, executar, registrar, mutantes |
| Estado e memória | o script guarda onde conseguir | objetivo por projeto, última execução, log |
| Na tela | texto no terminal | faixa acima do prompt e painel lateral |
| Verificar o comportamento | à mão | claude 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.
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.
| rodada | por mutante | testes por mutante | vereditos | |
|---|---|---|---|---|
| suíte completa em cada mutante | 407 s | 35,9 s | 8881 | 8 mortos, 2 sobreviventes |
| só os testes afetados | 145 s | 10,0 s | 1906 | os mesmos |
| idem, com o teste reforçado | 107 s | 6,4 s | 1018 | 9 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:
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.
prova-de-teste acha o comando de teste por convenção (claude plugin test, npm test, pytest). Para outro executor, passe comando ao executar.incremental: false desliga.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.
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:
| Toulmin | Aqui |
|---|---|
| claim | "a tarefa está feita", declarada no objetivo (contexto-bar) |
| grounds | red first e o relatório com cada mutante (prova-de-teste) |
| rebuttal | mutantes escolhidos às cegas por um modelo que não viu os testes |
| qualifier | o estado: PROVADO, PROVADO-PARCIAL, FRACO ou NÃO VERIFICADO |
$.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.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).
hooks/register.ts 774 lines1import { 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 })
774hooks/execucao.ts 579 lines1import 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}
579hooks/placar.ts 135 lines1// 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}
135hooks/tipos.ts 78 lines1/**
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}
78hooks/prova.ts 280 lines1import 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}
280hooks/alcance.ts 185 lines1/**
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
185types/index.d.ts 55 lines1export 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