Curso / Lição 29
Lição 29 · Avançado · a receita

Estendendo a fusão: a receita para um novo subsistema

Você leu sete subsistemas entregues e construiu dois próprios nos labs. Agora destile isso num checklist que um agente futuro pode seguir para adicionar um oitavo subsistema @alembic/hermes sem redescobrir as convenções. A receita não é gosto arbitrário — cada passo remete a um invariante ou ADR que você já conheceu. Siga-a e seu subsistema vai parecer que sempre esteve lá; pule um passo e o CI ou um revisor te pega, porque as regras são impostas, não sugeridas.

Leia primeiro (fontes primárias)
packages/hermes/src/index.ts · packages/hermes/package.json · CLAUDE.md "Rules for safe changes"

Esta lição destila as convenções já escritas no repositório — os blocos de cabeçalho por subsistema, a baseline de build/test e as regras de mudança segura. Cada número e cada linha de código tem fonte (rodapé). Por que importa pra missão: é o molde que mantém a fusão Alembic × Hermes coesa enquanto ela cresce — sem ele, cada novo subsistema reinventa um padrão diferente e a coerência se perde.

Objetivos desta lição
  • Reconhecer a forma única que todo subsistema compartilha: types.ts + <name>.ts + <name>.test.ts, exports nomeados de src/index.ts.
  • Aplicar os 8 passos da receita e citar, para cada um, o invariante ou ADR que o torna obrigatório.
  • Decidir a pergunta da dependência (um global do runtime resolve? então não adicione um pacote).
  • Fechar a disciplina: contagem de testes estável-ou-acima, suíte verde via test:safe, proveniência documentada.
0 arquivos
por subsistema (types · impl · test)
0 passos
na receita · cada um imposto
0 novas deps
a meta default (sem justificativa)
0
variação da contagem de testes
A forma que todo subsistema compartilha. Uma pasta sob packages/hermes/src/<name>/ com três arquivos: types.ts (schemas Zod + interfaces de porta), a implementação (uma classe ou funções sobre portas injetadas) e <name>.test.ts (fakes para cada porta). Todos os exports são nomeados e re-exportados de src/index.ts. É o template inteiro — memory, learning, clarify, web, skills, curator e media todos seguem.

01 · A forma que todo subsistema compartilha

Antes da receita de passos, veja o formato físico: o que é uma pasta de subsistema no disco e como ela se conecta ao resto do pacote. Os sete subsistemas entregues não são sete designs diferentes — são sete cópias do mesmo molde. Reconhecer o molde é o que te deixa adicionar o oitavo sem hesitar.

A PASTA DE UM SUBSISTEMA · três arquivos → re-export em src/index.ts → consumidores
packages/hermes/src/<name>/ types.ts — Zod + interfaces de porta <name>.ts — sobre portas injetadas <name>.test.ts — fakes por porta exports nomeados src/index.ts re-export + proveniência harness CLI Nada importa o arquivo de implementação direto: o consumidor importa só o que o index.ts re-exporta — a cintura estreita do pacote (Lição 14). Trocar a implementação por dentro não quebra ninguém de fora. Mesmo molde × 7 subsistemas entregues. Você adiciona o 8º copiando-o, não inventando um novo.

A receita inteira cabe num quadro. Cada passo é uma caixa; cada caixa traz por que ela existe — o invariante ou ADR que a impõe. Leia o pipeline da esquerda (portas) até a direita (suíte verde):

A RECEITA DE 8 PASSOS · de portas até uma suíte verde — cada passo imposto, não sugerido
① portas (injeção) ② fronteira Zod ③ Result<T,Error> ④ sem globais ⑤ sem nova dep ⑥ test:safe ⑦ export + docs ⑧ suíte verde pnpm -r typecheck && pnpm -r build && pnpm -w test → a baseline que deve ficar verde

A tabela abaixo é a mesma receita em texto — cada passo ao lado da regra que o obriga. Guarde esta correspondência: ela é o que transforma "convenção" em "contrato".

PassoFaçaPorque (invariante / ADR)
1Defina portas — passe IO/tempo/aleatoriedade como interfaces injetadas (FsPort, um backend, Clock, uma fábrica de ids)Invariante 2: kernel puro, efeitos colaterais injetados (ADR-0009)
2Valide toda entrada com Zod safeParse na fronteiraEntradas podem ser saída não confiável de modelo/rede (ADR-0011)
3Retorne Result<T, Error> de toda função falível; nunca lance na fronteira públicaA cintura estreita / nunca-lança (ADR-0009)
4Sem Date.now()/new Date()/Math.random() — injete um Clock/fábrica de idsDeterminismo & replay, invariante 3
5Não adicione nova dependência de runtime sem justificativa"Rules for safe changes" (CLAUDE.md)
6Adicione o vitest.config.ts por pacote endurecido + rode via test:safeSegurança de testes anti-órfão (Lição 25)
7Exporte símbolos nomeados de src/index.ts; documente a proveniência CLONE/ADAPT + fontesDescobribilidade + clean-room (ADR-0011 §4)
8Mantenha a suíte verde; a contagem total de testes deve aumentar ou ficar estávelToda feature precisa de testes (CLAUDE.md)

02 · Passo 1–2 — portas e schemas primeiro

Comece com types.ts. Antes de qualquer lógica, declare duas coisas: o que uma entrada válida é (Zod) e do que o subsistema depende (interfaces de porta). Pense numa porta como a tomada na parede: a implementação não sabe (nem quer saber) o que está plugado do outro lado — pode ser o backend real em produção ou um fake no teste. A tomada é um tipo, nunca uma classe concreta.

Em uma frase: types.ts responde "o que entra?" (schema Zod) e "do que eu preciso emprestado?" (portas). A implementação só recebe essas peças prontas — ela nunca vai buscar nada por conta própria. É o que deixa o subsistema testável (passa um fake), determinístico (passa um relógio fixo) e trocável (passa outro backend) de uma vez só.
Precisamente: uma porta é um type alias de função (ou interface) — ex.: as portas WebBackend + Compressor do subsistema web, ou ReviewProposer + ReviewGate do loop de aprendizado. O schema Zod gera o tipo da entrada via z.infer, então o validador de runtime e o tipo estático não podem divergir — uma única fonte de verdade para a fronteira.
// packages/hermes/src/<name>/types.ts
import { z } from 'zod';

export const requestSchema = z.object({ /* … campos limitados … */ });
export type Request = z.infer<typeof requestSchema>;

// Uma porta = uma capacidade injetada, nunca um import concreto:
export type Backend = (req: Request) =>
  Promise<import('@alembic/contracts').Result<Response, Error>>;   // nunca lança

Porta injetada vs. import concreto — o contraste

Por que insistir em "porta" em vez de simplesmente import { fetch } e pronto? Porque a tomada é o que separa o que o subsistema faz de como o efeito colateral acontece. Veja os dois desenhos lado a lado:

COMPARATIVO · import concreto (acoplado) vs. porta injetada (testável + trocável)
✗ import concreto impl chama node:fs / fetch direto efeito real acontece sempre teste precisa da rede / do disco real lento, não-determinístico, frágil ✓ porta injetada impl recebe deps = { backend } prod: backend real · teste: fake teste roda em memória, $0, determinístico e o backend é trocável sem mexer na impl

O passo 2 é o portão de entrada: toda entrada passa por safeParse antes de tocar a lógica. A razão é dura — no Alembic, as entradas frequentemente são saída de um modelo ou da rede, isto é, não confiáveis por padrão. O schema é a fronteira onde dado bruto vira dado tipado, ou é rejeitado como erro:

O PORTÃO ZOD · entrada não confiável → safeParse → dado válido OU err(...)
input: unknown modelo / rede / arquivo safeParse success? sim dado tipado → segue para a lógica não err(new Error(...)) sem lançar

03 · Passo 3–4 — Result em tudo, sem globais

O arquivo de implementação é funções (ou uma classe) sobre aquelas portas. Duas regras o governam, e ambas você já conheceu: todo caminho falível retorna Result (nunca um throw que vaza para fora), e tempo/aleatoriedade vêm de uma porta injetada — nunca de um global. É o esqueleto exato do Lab 1; não é um brinquedo, é o padrão de produção no tamanho mínimo.

// packages/hermes/src/<name>/<name>.ts
import { ok, err, tryCatchAsync, type Result } from '@alembic/contracts';
import { requestSchema, type Backend } from './types.js';

export const doThing = async (
  input: unknown, deps: { backend: Backend; now: () => number },
): Promise<Result<Response, Error>> => {
  const parsed = requestSchema.safeParse(input);          // ② Zod na fronteira
  if (!parsed.success) return err(new Error(parsed.error.message));
  return deps.backend(parsed.data);                        // ③ Result passa, ④ sem globais
};

Por que Result em vez de throw

Um throw some do tipo: quem chama não vê, pelo tipo, que aquela função pode explodir — descobre em runtime, talvez em produção. O Result<T, Error> torna o erro parte do contrato: o tipo de retorno diz que pode falhar, e o compilador obriga o chamador a tratar os dois ramos. É a "cintura estreita" do pacote — o erro flui como dado, não como exceção que pula a pilha.

throw (erro invisível no tipo) vs. Result (erro no contrato) — o caminho de cada um
throw função throw pula a pilha ↑ o tipo não menciona o erro: quem chama não é obrigado a tratar descoberto em runtime — talvez em prod Result<T, Error> função return ok(v) err(e) o tipo diz que pode falhar: o compilador obriga a tratar os dois ramos erro é dado, capturado no boundary
Preveja antes de continuar
Sua decisão de ciclo do subsistema precisa do tempo atual. A tentação é const t = Date.now() bem dentro da lógica. Antes de revelar: quantas das quatro invariantes do projeto essa única linha quebra — e o que mais ela impede?
Quebra o determinismo (invariante 3). Date.now() faz a mesma entrada produzir saídas diferentes a cada execução, então o replay deixa de reproduzir o run (Lição 28) e o teste não consegue fixar um valor — vira flaky. A VM de plano (alembic.plan.ts) inclusive rejeita Date.now()/new Date()/Math.random(). A correção é o passo 4: injete uma porta Clock (epoch ms) — produção passa o relógio real, o teste passa um fixo. Uma linha "inofensiva" custava replay + testabilidade de uma vez.
COMPARATIVO · Date.now() global vs. porta Clock injetada (determinismo + replay)
✗ Date.now() global a lógica lê o relógio do sistema run #1 ≠ run #2 replay não bate teste flaky sem valor fixo ✓ porta Clock injetada a lógica recebe now: () => number prod: Date.now replay reproduz teste: () => 42 determinístico

04 · Passo 5 — a pergunta da dependência

Antes de adicionar um pacote ao package.json, faça uma pergunta: um global que já está no runtime consegue fazer isso? Os subsistemas web e media responderam que sim — usam o fetch global por trás de um fino createFetchBackend em vez de adicionar um cliente HTTP (Lição 11). O subsistema skills escreveu seu próprio parser de frontmatter escalar, sem dependências, em vez de puxar uma lib de YAML (Lição 12). A regra do CLAUDE.md é direta: "não adicione novas dependências sem justificativa."

FLUXOGRAMA · "devo adicionar esta dependência?" — o caminho default termina em "não"
preciso de uma capacidade X (ex.: HTTP, parsing, hashing) um global do runtime (fetch, crypto…) resolve? SIM ✓ use o global atrás de um backend NÃO um helper trivial sem dep resolve? SIM ✓ escreva o helper como o parser de skills NÃO ↑ só então: adicione com justificativa escrita
Por que o default é "não". Uma nova dependência é uma superfície de supply-chain (você passa a confiar e atualizar código de terceiros) e um risco de clean-room (proveniência mais difícil de garantir, ADR-0011 §4). O custo não some depois do install — ele fica. Por isso a regra inverte o ônus: a dependência precisa se justificar, não o contrário.
O molde já provou isso. As dependências de runtime do pacote hermes são deliberadamente poucas — @alembic/contracts, @alembic/etl e zod. Sete subsistemas inteiros (web, media, skills, …) couberam sem nenhuma lib HTTP nem YAML. Se eles couberam, o oitavo quase sempre cabe também.

05 · Quanto custa, de fato, uma dependência? (calculadora)

"Uma dependência a mais" soa barato. Mas cada pacote chega com seus próprios sub-dependentes (a árvore transitiva). Arraste o slider: veja a árvore total que entra no projeto — e por que o degrau de 0 → 1 é o que mais pesa, não o de 9 → 10.

0 0 deps = a meta default do pacote hermes 0 → meta
0 deps · estado-alvo ≥1 dep · árvore transitiva cresce

Estimativa ilustrativa (~5 sub-dependentes por dep direta, número típico de árvores npm). [uncertain] o fator real varia muito por pacote; o ponto pedagógico — "0 → 1 é o salto de regime que abre a árvore transitiva" — é o que importa, não o número exato.

O salto que importa é 0 → 1
Ir de 9 para 10 deps já assume todo o aparato de supply-chain. O salto caro é 0 → 1: é ele que abre a árvore transitiva, o lockfile e a superfície de atualização. Por isso a meta do molde é literalmente zero deps novas por subsistema — e a régua para furá-la é uma justificativa escrita, não um encolher de ombros.

06 · Passo 6 — ligue a segurança de testes

Cada pacote carrega seu próprio vitest.config.ts (o pacote hermes tem um). Combine a config root endurecida — timeouts limitados e pool:'forks' — e sempre rode a suíte pelo wrapper de grupo de processo, nunca vitest puro. Por quê? Um teste que abre um socket ou inicia um timer e nunca o fecha pode órfãozar um worker — um processo que continua queimando CPU por horas depois que a suíte "terminou" (Lição 25). A config é a primeira linha de defesa; o test:safe é o piso.

# rode a suíte inteira com segurança (Lição 25):
pnpm test:safe            # run limitado no próprio grupo → mata o grupo + varredura
# ou um pacote:
pnpm --filter @alembic/hermes test
COMPARATIVO · vitest puro (worker pode orfanizar) vs. test:safe (grupo morto + varredura)
✗ vitest puro teste abre socket/timer e trava suíte "termina" — worker fica órfão (PPID=1) queima CPU por horas o bug real que a Lição 25 corrige ✓ pnpm test:safe run no próprio grupo de processo timeout limitado falha rápido mata o grupo + varre os restos CPU liberada · nada órfão
Por que a config por pacote importa. Um subsistema que abre um socket ou inicia um timer num teste deve falhar num timeout limitado, não travar um worker para sempre. Herdar o endurecimento testTimeout/teardownTimeout/forks (Lição 25) é parte do contrato — o wrapper de segurança de testes é o piso, a config é a primeira linha.

07 · Passo 7–8 — exporte, documente, verifique

Re-exporte todo símbolo público de src/index.ts com um comentário de cabeçalho nomeando a proveniência CLONE/ADAPT/IGNORE e as seções do mapa-fonte (leia o bloco de qualquer subsistema em index.ts — todos fazem isso). É o que torna o subsistema descobrível e mantém o rastro de clean-room. Depois rode a baseline e confirme que a contagem de testes moveu na direção certa:

# a baseline de build/test que toda mudança deve manter verde (CLAUDE.md):
pnpm -r typecheck && pnpm -r build && pnpm -w test
A BASELINE EM TRÊS ETAPAS · typecheck → build → test (qualquer uma vermelha = não está pronto)
pnpm -r typecheck tipos coerentes? pnpm -r build .d.ts p/ dependentes pnpm -w test suíte verde Erro de tipo depois de mudar um pacote? Rode pnpm -r build para os dependentes verem o novo .d.ts (CLAUDE.md).

A disciplina fecha aqui: "Toda feature nova precisa de testes; a contagem total de testes deve aumentar ou ficar estável." Um subsistema sem testes — ou um que derruba a contagem — não está pronto. Veja as duas trajetórias:

COMPARATIVO · contagem de testes ao adicionar um subsistema: aceita (↑/=) vs. rejeitada (↓)
antes depois (aceito) depois (rejeitado) testes N N + k ✓ novos casos N − j ✗ sem cobertura A regra "estável-ou-acima" é a definição mecânica de "feito" — um agente futuro não pode pular a cobertura em silêncio.

08 · O fluxograma da receita inteira (decisão)

Junte os oito passos numa única decisão que um agente futuro segue de cima a baixo. Cada losango é um portão imposto pelo CI, pela VM de plano ou por um revisor — não passa enquanto não estiver verde. Siga as setas: do "novo subsistema" até "merge", todo ramo de falha aponta para o que corrigir.

FLUXOGRAMA · ADICIONAR UM SUBSISTEMA — da pasta ao merge, cada portão imposto
novo subsistema <name>/ ① types.ts + impl + test.ts portas definidas ② Zod na fronteira, ③ Result, ④ sem globais? (VM de plano + tipos checam) NÃO corrija a fronteira SIM ⑤ precisou de nova dep? (global/helper não bastou) SIM justifique por escrito NÃO ✓ ⑥ test:safe · ⑦ export + docs config endurecida + proveniência ⑧ baseline verde e contagem ↑/= ? SIM ✓ MERGE sempre esteve lá NÃO → corrija e recomece

Confusões comuns

"Portas são over-engineering para um subsistema pequeno." Os labs provaram o oposto — o esqueleto de portas é o mínimo que rende testabilidade, determinismo e agnosticismo de store ao mesmo tempo. Uma chamada direta a node:fs economiza três linhas e te custa testes em memória, replay determinístico e um backend trocável. O padrão escala para baixo limpo.
"A receita é só convenção; posso desviar." A maior parte é imposta: a VM de plano rejeita Date.now(), o CI roda os tipos em forma de nunca-lança, o wrapper de testes mata órfãos, e um revisor checa a proveniência. Desviar não é um debate de estilo — é um build falhando ou uma mudança rejeitada.

09 · O checklist copia-e-cola

Tudo destilado numa lista que um agente marca item a item. O diagrama mostra o estado de cada caixa; a lista abaixo é o texto literal para colar num PR ou numa issue.

CHECKLIST DE "FEITO" · oito caixas — nenhuma é opcional, cada uma tem um portão atrás
Pasta <name>/ com types.ts + <name>.ts + <name>.test.ts → molde do pacote Toda entrada validada com Zod safeParse na fronteira → ADR-0011 Toda função falível retorna Result<T, Error>; nunca lança → ADR-0009 IO/tempo/aleatoriedade injetados — sem Date.now()/Math.random() → invariante 3 Sem nova dependência de runtime sem justificativa escrita → CLAUDE.md Testes usam fakes por porta; suíte roda via test:safe → Lição 25 Exports nomeados de src/index.ts com proveniência + fontes → ADR-0011 §4 pnpm -r typecheck && build && test verde; contagem ↑/= → a baseline Marque as oito e o subsistema "sempre esteve lá". Qualquer caixa vazia = um portão que vai te parar.
O checklist copia-e-cola
  • Pasta packages/hermes/src/<name>/ com types.ts + <name>.ts + <name>.test.ts
  • Toda entrada validada com Zod safeParse na fronteira
  • Toda função falível retorna Result<T, Error>; nunca lança publicamente
  • IO/tempo/aleatoriedade são portas injetadas (FsPort, backend, Clock, fábrica de ids) — sem node:fs, sem Date.now(), sem Math.random()
  • Sem nova dependência de runtime sem justificativa escrita
  • Testes usam fakes para cada porta; suíte roda via test:safe
  • Exports nomeados re-exportados de src/index.ts com proveniência + fontes
  • pnpm -r typecheck && pnpm -r build && pnpm -w test verde; contagem estável-ou-acima

10 · Exemplo resolvido — um subsistema do zero, passo a passo

A receita abstrata vira concreta quando você a aplica a um caso. Vamos seguir os oito passos para um subsistema hipotético digest/ (resumir o conteúdo de um pacote do wiki) — exatamente o que um agente futuro faria. Em cada passo, a coluna da direita mostra o portão que verificaria aquele passo. Nota: digest é o <name> de exemplo da receita, não um subsistema já entregue — os sete entregues são memory · learning · clarify · web · skills · curator · media.

Exemplo resolvido · aplicar os 8 passos a um subsistema digest/ (hipotético)
1
Defina portas em types.ts. O resumo depende de IO (ler o pacote) e de tempo (carimbar o digest). Em vez de chamar node:fs e Date.now(), declare-os como portas: FsPort (do @alembic/etl) e now: () => number. Portão: invariante 2 — kernel puro, efeitos injetados (ADR-0009).
2
Schema Zod na fronteira. A entrada (o caminho do pacote + opções) pode vir de um modelo ou da CLI — não confiável. const digestSchema = z.object({ path: z.string(), maxChars: z.number().int().positive() }); e safeParse antes de qualquer lógica. Portão: ADR-0011 — entrada é saída não confiável de modelo/rede.
3
Retorne Result<T, Error>. A função digest(input, deps) devolve Promise<Result<Digest, Error>>: err(...) se o parse falhar, ok(...) no caminho feliz. Nenhum throw cruza a fronteira pública. Portão: a cintura estreita / nunca-lança (ADR-0009), checado pelos tipos.
4
Sem globais. O carimbo do digest usa deps.now(), não Date.now(); nenhum Math.random(). Portão: determinismo (invariante 3) — a VM de plano rejeita Date.now()/new Date()/Math.random().
5
A pergunta da dependência. Preciso de uma lib de markdown? Faça a pergunta: um global ou um helper trivial resolve? Para um resumo por truncamento + heurística simples, sim — escreva o helper, como o subsistema skills escreveu seu próprio parser de frontmatter (Lição 12). Zero deps novas. Portão: "Rules for safe changes" (CLAUDE.md) — dep nova exige justificativa escrita.
6
Teste com fakes + test:safe. digest.test.ts passa um FsPort fake (mapa em memória) e now: () => 42. A suíte roda via pnpm test:safe (nunca vitest puro), herdando os timeouts limitados. Portão: segurança de testes anti-órfão (Lição 25).
7
Exporte + documente a proveniência. Re-exporte digest, digestSchema e os tipos de src/index.ts, com um bloco de cabeçalho nomeando CLONE/ADAPT/IGNORE e as seções do mapa-fonte — como todo bloco já faz em index.ts. Portão: descobribilidade + clean-room (ADR-0011 §4), checado em revisão.
8
Baseline verde + contagem ↑/=. pnpm -r typecheck && pnpm -r build && pnpm -w test passa, e a contagem total de testes subiu com os casos do digest. Portão: toda feature precisa de testes; contagem estável-ou-acima (CLAUDE.md).
Agora você: seu subsistema digest também precisa baixar um pacote remoto por HTTP antes de resumir. Aplique o passo 5 — qual a decisão sobre a dependência, e por quê? Decida antes de revelar.
Não adicione um cliente HTTP. O fetch global já está no runtime — use-o por trás de um fino backend injetado (createFetchBackend), exatamente como os subsistemas web e media fizeram (Lição 11). Isso mantém 0 deps novas, deixa o teste passar um fake em vez de bater na rede e preserva o determinismo. Dica: o procedimento é sempre o mesmo — a pergunta da dependência só muda de capacidade (HTTP), nunca de regra.

O mesmo caso, visto como um traço: o subsistema digest entrando por cada um dos oito portões, com o veredito de cada um. É a receita "rodando" sobre um exemplo — repare que todo portão fica verde porque cada passo foi feito na ordem.

TRAÇO APLICADO · o subsistema digest/ passando pelos 8 portões — cada um verde
digest/ novo subsistema ① portas ✓FsPort + now() ② Zod ✓safeParse ③ Result ✓ok/err ④ sem globais ✓deps.now() ⑤ 0 dep nova ✓helper próprio ⑥ test:safe ✓fakes por porta ⑦ export+docs ✓proveniência ⑧ baseline verde · contagem ↑ typecheck && build && test ✓ digest/ pronto p/ merge parece que sempre esteve lá Verde nos oito = "sempre esteve lá". Um único portão vermelho — uma dep injustificada, um Date.now(), um teste faltando — e o traço para naquele losango: build vermelho ou PR rejeitado. A ordem da receita é o que mantém todos verdes de uma vez.

11 · Como isso se encaixa

Esta receita não é uma lição avulsa: é o nó que amarra quatro peças do curso num único caminho repetível — "adicionar um subsistema". Ela toma o framework de fusão (Lição 21) como a decisão de o quê portar, o padrão de ports & injeção (Lição 05) como a forma de cada arquivo, os labs (Lições 22–23) como o treino de construir a peça à mão, e a segurança de testes (Lição 25) como o piso de como rodá-la — e os funde nos 8 passos. Veja o que alimenta a receita (a montante) e o que ela passa a produzir (a jusante): um subsistema que atravessa os gates e pluga no motor.

ONDE A PEÇA ENTRA · a montante alimenta a receita → a receita de 8 passos (esta peça) → a jusante: um subsistema que pluga no motor
A MONTANTE · alimenta a receita framework de fusão Lição 21 — o quê portar (CLONE/ADAPT) ports & injeção Lição 05 — a forma de cada arquivo labs · um subsistema à mão Lições 22–23 — o treino segurança de testes Lição 25 — o piso de como rodar a receita de 8 passos ESTA peça · esta lição portas · Zod · Result · sem globais sem dep · test:safe · export+docs · baseline cada passo herda um invariante/ADR das peças a montante — nada é gosto novo A JUSANTE · o que a receita produz pipeline de gates Lição 17 — Proof valida o subsistema loop do agente Lição 19 — pluga como os outros trilha de ADRs Lição 24 — a proveniência registrada Leitura da esquerda p/ a direita: o framework + os ports + os labs + a segurança de testes alimentam a receita; aplicada, ela produz um subsistema validado pelos gates, plugado no loop e com proveniência registrada. É o caminho repetível "adicione um subsistema" — quatro peças do curso fundidas num só procedimento. Os labs (22–23) ensinam a peça por dentro; esta receita a coloca de fora do exercício, no motor real.
Percorra o fluxo: nada aceso ainda — clique para acender a montante

As peças que esta receita amarra — cada uma é uma lição que abre uma face do mesmo caminho:

Onde você está na metodologia. O motor do Alembic é um loop — aprender → analisar → executar uma unidade → verificar na fronteira real → decidir — e cada subsistema é uma peça que esse loop executa e verifica. Esta lição é a planta da fábrica: o procedimento que pega uma peça nova e a faz nascer com a forma exata que o motor exige, atravessar os gates (Lição 17) e plugar no loop do agente (Lição 19) sem nenhum caso especial. As Lições 22–23 são a oficina (você fabrica uma peça à mão); esta receita é o manual que torna esse trabalho repetível para qualquer subsistema futuro. Para ver a esteira inteira em movimento, abra o mapa interativo da metodologia.

12 · Na prática

A receita vira comandos concretos. Como um subsistema é uma peça do motor (não um run montado), a prova certa não é um alembic run — é o pacote compilando e os testes passando. O ciclo de verdade tem três comandos, na ordem da receita: rode o teste do pacote enquanto itera, rode a suíte inteira com a segurança de testes da Lição 25, e feche com a baseline completa do CLAUDE.md. São exatamente os comandos que o projeto manda manter verdes a cada mudança de código.

1) Com a pasta <new>/ e seu vitest.config.ts por pacote no lugar (passo 6), rode só o teste do pacote enquanto itera — o ciclo de feedback curto:

# roda apenas o pacote do novo subsistema (substitua <new> pelo nome do pacote)
pnpm --filter @alembic/<new> test

# saída esperada (forma — Vitest):
#  ✓ src/<name>/<name>.test.ts (3)
#    ✓ rejeita entrada inválida e nunca lança
#    ✓ valida e retorna ok no caminho feliz
#    ✓ usa now() injetado — determinístico
#  Test Files  1 passed (1)
#       Tests  3 passed (3)
Nota — o vitest.config.ts por pacote. Um @alembic/* novo precisa do próprio vitest.config.ts (com setupFiles em caminho absoluto) para que pnpm --filter <new> test rode isolado; sem ele o filtro não encontra o teste. [uncertain] isto vale ao criar um pacote do zero — se o seu subsistema mora num pacote que tem testes (p.ex. dentro de packages/hermes/), o config já existe e você não precisa criar outro.

2) Rode a suíte inteira pela segurança de testes — nunca vitest puro. O wrapper roda no próprio grupo de processo e o mata ao final, então um teste que abre um socket ou timer não orfaniza um worker queimando CPU por horas (Lição 25):

# roda a suíte inteira com segurança de grupo de processo (Lição 25):
pnpm test:safe

# o que observar:
#  - a suíte termina e o processo volta ao prompt (nada fica em segundo plano)
#  - nenhum worker órfão (PPID=1) consumindo CPU depois do fim

3) Feche com a baseline do repositório inteiro — tipos, build e todos os testes. É o portão do CLAUDE.md ("Every code change must keep these green"), o passo 8 da receita:

# a baseline canônica — precisa terminar com exit 0
pnpm -r typecheck && pnpm -r build && pnpm -w test

# o que observar:
#  - typecheck: 0 erros (se quebrar após mexer num pacote, rode `pnpm -r build`
#    para os dependentes verem o novo .d.ts)
#  - build: todos os pacotes compilam
#  - test: a contagem total sobe ou fica igual — nunca cai
Por que nenhum comando de runtime aqui. Comandos como alembic run ou alembic serve exercitam o motor já montado; o que esta receita entrega é uma peça dele. A prova certa de uma peça nova é a baseline de build/test — não um run. Quando o subsistema for ligado de fato a um run (o passo 7 cobre o exportar + documentar), aí sim ele aparece num alembic run. [uncertain] não há um comando alembic que exercite um subsistema isolado — a verificação canônica é pnpm --filter <new> test + test:safe + a baseline.
Experimente · do scaffold ao verde
  1. Clone e entre no monorepo: git clone <repo> alembic && cd alembic, depois pnpm install. O que observar: o workspace resolve os pacotes @alembic/* sem erro.
  2. Crie a pasta do subsistema — p.ex. packages/hermes/src/<name>/ — com os três arquivos do molde (types.ts, <name>.ts, <name>.test.ts) das seções 02–06. Se for um pacote novo do zero, adicione também o vitest.config.ts por pacote (passo 6).
  3. Itere no curto: pnpm --filter @alembic/<new> test. O que observar na saída: o arquivo <name>.test.ts com os casos em verde e nenhuma menção a diretório temporário — os fakes rodam em memória.
  4. Rode a suíte com segurança: pnpm test:safe. O que observar: a suíte termina e o prompt volta — nenhum worker fica queimando CPU em segundo plano.
  5. Feche com a baseline: pnpm -r typecheck && pnpm -r build && pnpm -w test. O que observar: exit 0 e a contagem total de testes maior do que antes de você começar.

Vale ver o ciclo como uma esteira de quatro passos — scaffold, itera no curto, roda seguro, fecha no completo — e só então a peça é uma unidade pronta que pluga no motor:

O CICLO DE PROVA DA RECEITA · scaffold → teste do pacote (loop) → test:safe → baseline → pronto
scaffold do pacote 3 arquivos + vitest.config pnpm --filter <new> test loop curto · vermelho → verde enquanto falhar, ajuste e repita verde pnpm test:safe suíte inteira · nada órfão baseline do repo typecheck·build·test exit 0 · contagem ↑/= unidade pronta ✓ pluga no motor sem caso especial O teste do pacote é o loop de iteração; test:safe + a baseline são o portão de "pronto". A peça só é uma unidade depois que a suíte roda segura E a baseline passa.

Fixe a receita (flashcards)

Clique pra virar. Tente lembrar a resposta antes de virar — recuperação ativa fixa mais que reler.

Forma
Quais três arquivos todo subsistema tem?
clique pra virar ↻
Resposta
types.ts (Zod + portas), <name>.ts (impl sobre portas) e <name>.test.ts (fakes por porta). Tudo re-exportado de src/index.ts.
Passo 4
O subsistema precisa do tempo. O que injetar?
clique pra virar ↻
Resposta
Uma porta Clock (epoch ms): prod passa Date.now, teste passa um valor fixo. Nunca Date.now() na lógica — a VM de plano o rejeita (invariante 3).
Passo 5
Qual a pergunta antes de adicionar uma dep?
clique pra virar ↻
Resposta
"Um global do runtime resolve?" Web/media usam fetch via createFetchBackend; skills escreveu seu parser. Default = 0 deps; furar exige justificativa.
Passo 8
Quando um subsistema está "pronto"?
clique pra virar ↻
Resposta
Quando pnpm -r typecheck && build && test fica verde via test:safe E a contagem de testes aumenta ou fica estável. Sem testes / contagem que cai = não está pronto.

Revisão cumulativa — recupere de memória

Antes de clicar: responda de cabeça. As quatro opções têm o mesmo tamanho de propósito — sem pista pela forma.

1. Você precisa de uma chamada HTTP num novo subsistema. O que a receita manda fazer primeiro?
Correto: b. Os subsistemas web e media usam o fetch global via um fino backend — sem dependência HTTP — e o backend é injetado, então testes passam um fake em vez de bater na rede. a viola a regra "não adicione novas dependências sem justificativa" (CLAUDE.md) e abre a árvore transitiva à toa. c chama o efeito real direto: o teste passa a exigir a rede, perdendo determinismo (passo 4). d reinventa um global que já existe — esforço puro, e ainda sem injeção.
2. A decisão de ciclo do seu subsistema depende do tempo atual. A receita exige:
Correto: d. O passo 4 espelha o curador: tempo é um efeito colateral, injetado como qualquer outro; um Clock torna o ciclo determinístico no teste e replay-safe em produção (Lição 28). a está errado de fato — o problema do Date.now() não é só o módulo de plano: ele quebra replay e deixa o teste flaky em qualquer lugar. b hardcodar uma data congela o comportamento e mente em produção. c a variável de ambiente ainda é um global escondido — não é injetada nem fixável por teste.
3. Por que a receita insiste que a contagem total de testes "aumente ou fique estável" e que a suíte rode via test:safe?
Correto: c. A regra de contagem impõe que toda nova behavior seja coberta; test:safe (Lição 25) garante que um teste travado não fixe CPU por horas. a inverte o objetivo — a meta é uma suíte rápida e confiável, não lenta. b trata como estilo o que é contrato: ambos são impostos. d é factualmente falso — o Vitest não impõe contagem mínima; a regra "estável-ou-acima" é do projeto, não da ferramenta.
4. O que torna a "receita" um contrato, e não só uma sugestão de estilo?
Correto: a. A força da receita é que ela é enforced em pontos diferentes — VM de plano (determinismo), tipos (Result/nunca-lança), wrapper test:safe (órfãos) e revisão (proveniência). b confunde com formatação: o ponto não é estética, é correção e segurança. c é o erro que a lição inteira combate — desviar dá build vermelho ou PR rejeitado, não um ombro encolhido. d superestima o TS: ele cobre tipos, mas não roda test:safe, não impõe a contagem nem checa a proveniência — por isso a receita combina vários portões.
💬 Travou em algo? Eu sou seu professor neste curso — pergunte. "Posso ter mais de um backend numa só porta?", "Como escrevo o fake de um Clock no teste?", "Quando uma nova dependência se justifica?". É só dizer.