Curso / Lição 29  ·  EN
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.

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.

A receita num relance

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)
packages/hermes/src/<name>/ types.ts — Zod + interfaces de porta <name>.ts — sobre portas injetadas <name>.test.ts — fakes por porta a receita de 8 passos (cada passo imposto, não sugerido) ① portas ② fronteira Zod ③ Result ④ sem globais ⑤ sem nova dep ⑥ test:safe ⑦ export + docs ⑧ suíte verde · contagem estável-ou-acima Cada passo remete a um invariante ou ADR — pule um e o CI, a VM de plano ou um revisor te pega. pnpm -r typecheck && pnpm -r build && pnpm -w test → a baseline que deve ficar verde

Passo 1–2 — portas e schemas primeiro

Comece com types.ts. Defina o que uma entrada válida é (Zod) e do que o subsistema depende (interfaces de porta). Os subsistemas entregues fazem exatamente isso — ex.: as portas WebBackend + Compressor do subsistema web, as portas ReviewProposer + ReviewGate do loop de aprendizado. Portas são tipos, nunca classes concretas:

// 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

Passo 3–4 — Result em tudo, sem globais

O arquivo de implementação é funções ou uma classe sobre essas portas. Todo caminho falível retorna Result; IO é envolvido em tryCatchAsync; tempo/aleatoriedade vêm de um Clock/fábrica de ids injetados, nunca um global (Lição 28). É 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
};

Passo 5 — a pergunta da dependência

Antes de adicionar um pacote, pergunte: um global já no runtime consegue fazer isso? Os subsistemas web e media responderam que sim — usam o fetch global via 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 YAML (Lição 12). A regra: "Não adicione novas dependências sem justificativa." Uma nova dep é uma superfície de supply-chain e um risco de clean-room — mereça-a ou evite-a.

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:

# 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
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.

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). Depois rode a baseline e confirme que a contagem 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 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.

O checklist copia-e-cola
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. "Não adicione novas dependências sem justificativa" (CLAUDE.md).
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. Uma porta Clock torna o ciclo determinístico no teste e replay-safe em produção (Lição 28), e mantém o código uniforme com o resto do pacote.
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 receita assa ambos na definição de "pronto", para que um agente futuro não possa pular nenhum em silêncio.

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.