Lição 29 · Curso de Fusão · Parte 5 · Engenharia · Estendendo a fusão
Parte 5 · Engenharia · Lição 29

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

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

Cada bloco tem uma versão Simples e uma Técnica. Abra a técnica quando quiser o código real.
O que presumimos de você
  • Que você já viu ports e injeção (lição 5), o vazamento de órfãos do Vitest (lição 6 / test:safe, lição 25) e o determinismo (lição 28).
  • Que "subsistema" aqui é só uma pasta com três arquivos dentro de um pacote. Explicamos a forma do zero.
  • Nada além disso. Não é preciso ter escrito um subsistema antes — esta é a planta para o primeiro (ou o oitavo).
1

A grande ideia


Ao terminar esta lição você consegue
  • Recitar a forma de três arquivos que todo subsistema de @alembic/hermes compartilha.
  • Seguir os oito passos da receita e dizer qual invariante ou ADR justifica cada um.
  • Decidir a "pergunta da dependência" — quando um global do runtime já resolve.
  • Reconhecer que a maior parte da receita é imposta (VM, CI, wrapper de teste, revisor), não opcional.

A maioria dos sistemas adiciona um subsistema novo do jeito que der: cada um com seu formato, suas dependências, seu estilo. O @alembic/hermes faz o contrário — há uma forma, repetida sete vezes, e a oitava deve ser a mesma. A receita captura essa forma como uma lista que um agente futuro segue sem redescobrir nada.

Pense numa fábrica de móveis com um gabarito: cada gaveta sai do mesmo molde, com os mesmos encaixes. Não é falta de criatividade — é o que faz qualquer peça encaixar no resto sem ajuste. A receita é o gabarito do subsistema.
Por que destilar numa receita? Porque um agente que chega depois não tem o contexto que você acabou de construir. Sem a checklist, ele reinventa convenções — e diverge. Com ela, o oitavo subsistema é indistinguível dos sete primeiros, e o CI confirma isso automaticamente.
sete enviados, todos com a mesma forma → o oitavo deve repeti-la memory learning curator clarify web skills media <nome>o 8º Cada caixa = uma pasta de três arquivos (types.ts · <nome>.ts · <nome>.test.ts) re-exportada por index.ts. A receita é o molde tracejado: oito passos para a oitava caixa virar idêntica às sete.
A forma se repete sete vezes; a receita garante que a oitava também a siga.
Infográfico da receita de oito passos para um novo subsistema. À esquerda, uma pasta packages/hermes/src/<nome>/ com três arquivos empilhados: types.ts (Zod + ports), <nome>.ts (sobre ports injetados), <nome>.test.ts (fakes por port). À direita, uma esteira de oito cartões numerados: ① ports, ② fronteira Zod, ③ Result, ④ sem globais, ⑤ sem nova dependência, ⑥ test:safe, ⑦ export + docs, ⑧ suíte verde com contagem estável-ou-acima. Faixa de fundação: pnpm -r typecheck && pnpm -r build && pnpm -w test, a baseline que deve permanecer verde.

A receita de oito passos — cada passo rastreia a uma invariante ou um ADR. A baseline na fundação é o que deve permanecer verde.

2

A forma que todo subsistema compartilha


Uma pasta sob packages/hermes/src/<nome>/ com três arquivos: types.ts (schemas Zod + interfaces de port), a implementação (uma classe ou funções sobre ports injetados) e <nome>.test.ts (fakes para cada port). Todos os exports são nomeados e re-exportados de src/index.ts. Esse é o template inteiro — memory, learning, clarify, web, skills, curator e media seguem todos ele.

Uma pasta, três arquivos, um ponto de export packages/hermes/src/<nome>/ types.ts — Zod + interfaces de port <nome>.ts — sobre ports injetados <nome>.test.ts — fakes por port re-export src/index.ts só exports NOMEADOS + cabeçalho de proveniência os sete que já seguem memorylearningclarify webskillscuratormedia
O mesmo molde, sete vezes. O oitavo subsistema é a oitava cópia — não uma estrutura nova.
Sacada Quando você abrir packages/hermes/src/index.ts, vai ver um bloco de cabeçalho por subsistema dizendo se ele é um CLONE, um ADAPT ou um IGNORE, e apontando as seções do mapa de origem. O oitavo subsistema ganha o seu próprio bloco no mesmo formato.
A fonte para ler verbatim
packages/hermes/src/index.ts — os cabeçalhos por subsistema (proveniência CLONE/ADAPT/IGNORE + seções do mapa) e todos os exports nomeados. É a planta literal que esta receita generaliza.
3

A receita num relance


Preveja antes de continuar

Dos oito passos abaixo, qual você acha que mais gente acha que é só "estilo" e pode pular?

Os passos 4 e 6 — sem globais e test:safe. Parecem detalhes, mas são exatamente os que a máquina impõe: a plan-VM rejeita Date.now()/Math.random(), e o wrapper test:safe mata workers órfãos. Pular não é uma escolha de estilo — é um build quebrado ou CPU travada por horas.
PassoFaçaPorque (invariante / ADR)
1Defina ports — passe IO/tempo/aleatoriedade como interfaces injetadas (FsPort, um backend, Clock, uma id factory)Invariante 2: kernel puro, efeitos colaterais injetados (ADR-0009)
2Valide com Zod safeParse toda entrada 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 através da fronteira públicaA cintura estreita / nunca-lança (ADR-0009)
4Sem Date.now()/new Date()/Math.random() — injete um Clock/id factoryDeterminismo & replay, invariante 3
5Não adicione nova dependência de runtime sem justificativa"Regras para mudanças seguras" (CLAUDE.md)
6Adicione o vitest.config.ts endurecido por pacote + rode via test:safeTest-safety anti-órfão (lição 25)
7Exporte símbolos nomeados de src/index.ts; documente a proveniência CLONE/ADAPT + as fontesDiscoverabilidade + clean-room (ADR-0011 §4)
8Mantenha a suíte verde; a contagem total de testes deve subir ou ficar estávelToda feature precisa de testes (CLAUDE.md)
a esteira — cada caixa é um passo, cada passo é imposto
src/<nome>/ types.ts <nome>.ts <nome>.test.ts fronteira de dados determinismo & dependências discoverabilidade & prova ① portsinjete IO/tempo ② ZodsafeParse ③ Resultnunca lança ④ sem globaisClock injetado ⑤ sem nova depsó c/ justific. ⑥ test:safeconfig + wrapper ⑦ export+docsproveniência ⑧ suíte verdecontagem ↑/= cada passo rastreia a uma invariante ou ADR — pule um e o CI, a plan-VM ou um revisor pega. pnpm -r typecheck && pnpm -r build && pnpm -w test → a baseline que deve permanecer verde
4

Passos 1–2 — ports e schemas primeiro


Comece pelo types.ts. Defina o que uma entrada válida é (Zod) e de que o subsistema depende (interfaces de port). Os subsistemas em produção fazem exatamente isso — por exemplo, os ports WebBackend + Compressor do subsistema web, ou os ports ReviewProposer + ReviewGate do loop de aprendizado. Ports são tipos, nunca classes concretas.

É como definir os encaixes de uma peça antes de fabricá-la: você diz "aqui entra um parafuso deste tamanho" sem decidir qual parafuso. Na produção entra o parafuso de verdade; no teste, um de brinquedo. O encaixe — o port — é o mesmo.

types.ts — schema de entrada + port

O schema descreve a entrada com campos limitados; o port é uma capacidade injetada, declarada como tipo (uma função ou interface), nunca um import concreto. Repare que o backend já promete Result e "never throws".

// packages/hermes/src/<nome>/types.ts
import { z } from 'zod';

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

// Um port = uma capacidade injetada, nunca um import concreto:
export type Backend = (req: Request) =>
  Promise<import('@alembic/contracts').Result<Response, Error>>;   // nunca lança
Defina o contrato antes da implementação types.ts requestSchema (Zod) type Backend (port) <nome>.ts recebe os ports como deps safeParse na fronteira <nome>.test.ts passa um FAKE backend roda em memória, $0
O port declarado em types.ts é o que o teste substitui por um fake — testabilidade nasce aqui.
5

Passos 3–4 — Result em tudo, sem globais


O arquivo de implementação é uma classe ou funções sobre aqueles ports. Todo caminho falível devolve Result; o IO é embrulhado em tryCatchAsync; tempo e aleatoriedade vêm de um Clock/id factory injetado, nunca de um global (lição 28). Esse é o mesmo esqueleto do Lab 1 — não é um brinquedo, é o padrão de produção no tamanho mínimo.

É a diferença entre uma máquina que estoura um fusível na sua cara e uma que acende uma luz num painel: a falha vira um dado previsível que você lê, em vez de uma exceção que escapa.

<nome>.ts — Result na fronteira, tempo injetado

A função valida com safeParse (passo ②), devolve Result em todo ramo (passo ③) e recebe now como dependência em vez de chamar Date.now() (passo ④). Quem chama checa o discriminante .ok — nunca embrulha em try/catch esperando surpresa.

// packages/hermes/src/<nome>/<nome>.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);          // ② fronteira Zod
  if (!parsed.success) return err(new Error(parsed.error.message));
  return deps.backend(parsed.data);                        // ③ Result passa, ④ sem globais
};
passo ③ — a forma de Result
Result<T,E> ok:true · value ok:false · error
passo ④ — tempo é uma dependência
Date.now() ✗ a plan-VM rejeita prod: relógio real teste: relógio fixo mesmo código → replay-safe
Guarde isto Falha é dado; tempo é dependência. O ramo err é um valor que você propaga, e o relógio chega como argumento — é o que mantém o subsistema testável e replayável de uma só vez.
6

Passo 5 — a pergunta da dependência


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

Preciso mesmo de uma nova dependência? um global do runtime resolve? sim embrulhe num port fino ex.: createFetchBackend (web/media) não escreva uma justificativa senão, escreva você (skills: frontmatter) @alembic/hermes depende só de: @alembic/contracts · @alembic/etl · zod
O package.json do @alembic/hermes tem só três dependências de runtime. Toda dep nova teria que se justificar contra essa linha.
Por que isto é uma regra, não um capricho. Cada dependência é código que você não escreveu rodando dentro do seu — uma superfície de ataque de supply-chain e, num projeto que faz clean-room, um risco de proveniência. Os sete subsistemas provam que dá para ir longe com fetch global e um pouco de código próprio.
7

Passo 6 — conecte a test-safety


Cada pacote carrega seu próprio vitest.config.ts (o pacote hermes tem um). Espelhe a config endurecida da raiz — timeouts limitados e pool:'forks' — e rode a suíte sempre pelo wrapper de grupo de processo, nunca pelo vitest pelado:

# 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 test:safe e não vitest pelado vitest pelado teste pendurado → worker órfão, CPU travada horas test:safe grupo de processo próprio + config endurecida (timeouts, forks) timeout limitado → falha, não pendura mata o grupo + varre órfãos ao final CPU liberada · CI confiável
A config por pacote é a primeira linha; o wrapper test:safe é o piso. Juntos, um teste pendurado falha em vez de travar a máquina.
Por que a config por pacote importa. Um subsistema que abre um socket ou inicia um timer num teste precisa falhar num timeout limitado, não pendurar um worker para sempre. Herdar o endurecimento de testTimeout/teardownTimeout/forks (lição 25) faz parte do contrato — o wrapper de test-safety é o piso, a config é a primeira linha de defesa.
8

Passos 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 de origem (leia o bloco de qualquer subsistema no 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 se fecha aqui: "Toda feature nova precisa de testes; a contagem total de testes deve subir ou ficar estável." Um subsistema sem testes, ou que derruba a contagem, não está pronto.

A checklist de copiar-colar
  • Pasta packages/hermes/src/<nome>/ com types.ts + <nome>.ts + <nome>.test.ts
  • Toda entrada validada com Zod safeParse na fronteira
  • Toda função falível retorna Result<T, Error>; nunca lança em público
  • IO/tempo/aleatoriedade são ports injetados (FsPort, backend, Clock, id factory) — sem node:fs, sem Date.now(), sem Math.random()
  • Sem nova dependência de runtime sem justificativa escrita
  • Os testes usam fakes para cada port; a 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
Infográfico comparativo: a receita é imposta, não opcional. No centro, quatro porteiros empilhados que bloqueiam uma seta de mudança: a plan-VM rejeita Date.now()/Math.random(); os tipos no formato never-throws rodam no CI; o wrapper test:safe mata workers órfãos; um revisor confere a proveniência CLONE/ADAPT. À esquerda, uma seta vermelha 'mudança que pula um passo' batendo no primeiro porteiro e recebendo o carimbo REJEITADA. À direita, uma seta oliva 'mudança que segue a receita' passando limpa pelos quatro porteiros até a caixa-alvo 'suíte verde, contagem estável-ou-acima'.

A receita é imposta em quatro pontos — VM, CI, wrapper de teste e revisor. Pular um passo é um build quebrado ou uma mudança rejeitada, não um debate de estilo.

9

Explorador da receita


Clique num passo para ver o que ele exige, por quê, e como um subsistema real o cumpre.

fronteira de dados

① Defina ports

Passe IO, tempo e aleatoriedade como interfaces injetadas — um backend, FsPort, Clock, uma id factory. Ports são tipos, nunca classes concretas.

Porque: invariante 2 — kernel puro, efeitos colaterais injetados (ADR-0009). Exemplo: web usa WebBackend + Compressor.
① defina ports ② Zod safeParse na fronteira ③ Result em toda função falível ④ sem Date.now()/Math.random() ⑤ sem nova dependência ⑥ config por pacote + test:safe ⑦ export nomeado + proveniência ⑧ suíte verde · contagem ↑/=
10

Por que uma receita — e não "bom senso"


"Bom senso" é um conselho: cada agente interpreta do seu jeito, e o oitavo subsistema sai diferente dos sete. A receita é uma lista fechada: siga-a e você não tem como divergir. Cada passo existe porque compra uma propriedade verificável — testabilidade (① ②), determinismo (④), segurança de execução (⑥), proveniência (⑦) — não porque é elegante.

A maior parte da receita é imposta por máquina, não por documentação: a plan-VM rejeita Date.now() (④); os tipos no formato never-throws e o typecheck pegam um throw público (③); o wrapper test:safe mata órfãos (⑥); o build/test da baseline falha se a suíte quebrar ou a contagem cair (⑧). Os passos que sobram (⑤ dependência, ⑦ proveniência) caem na revisão humana. Não há "deixa pra depois" silencioso — ou passa nos quatro pontos de imposição, ou não entra.

11

Exemplo guiado — qual passo cada decisão satisfaz?


Você vai adicionar um subsistema notify (envia um aviso quando um run termina). Para cada decisão, qual passo da receita ela cumpre — ou viola?

Diagnóstico passo a passo
1
Você cria packages/hermes/src/notify/types.ts com notifyRequestSchema (Zod) e um type NotifyBackend = (req) => Promise<Result<…>>. → Cumpre ① e ②. O port é um tipo injetado; a entrada tem schema. Testabilidade garantida.
2
A implementação precisa de um carimbo de tempo no aviso. Você chama Date.now() direto. → Viola ④. Injete now: () => number em vez disso — a plan-VM rejeitaria isso num módulo de plano, e quebra o replay.
3
Para o POST HTTP, você quer adicionar node-fetch ao package.json. → Viola ⑤. O fetch global já está no runtime (Node 18+); embrulhe-o num createNotifyBackend fino, como web/media fizeram. Nenhuma dep nova.
4
Você escreve o teste passando um fakeNotifyBackend e roda pnpm test:safe; a contagem total sobe de N para N+3. → Cumpre ⑥ e ⑧. Suíte verde pelo wrapper, contagem para cima — pronto para o re-export nomeado (⑦).
notify — cada decisão contra a receita (verde = cumpre, vermelho = viola) decisão 1 · types.tsport + schema decisão 2 · Date.now()timestamp do aviso decisão 3 · node-fetchPOST HTTP decisão 4 · test:safefake + N→N+3 cumpre ① ② viola ④ viola ⑤ cumpre ⑥ ⑧ Repare: nenhuma decisão é neutra — cada uma cai sobre um passo nomeado da receita.
O mesmo diagnóstico do bloco acima, como mapa: duas decisões cumprem, duas violam — sempre sobre um passo nomeado.
Agora você: imagine uma quinta decisão — "exportei NotifyBackend de src/index.ts, mas sem comentário de cabeçalho dizendo que é um CLONE de tal fonte". Qual passo isso parece cumprir, mas na verdade deixa incompleto? (Resposta: o ⑦. O export nomeado está lá, mas a metade da proveniência/clean-room falta — e é justo o que um revisor cobra.)
12

Cartões de memória


Tente responder antes de virar cada cartão (prática de recuperação).

A forma
Quais são os três arquivos de um subsistema?
clique para virar
types.ts (Zod + ports), <nome>.ts (sobre ports injetados) e <nome>.test.ts (fakes por port). Tudo re-exportado de src/index.ts.
Passo 5
Precisa de um HTTP call. O que a receita manda fazer primeiro?
clique para virar
Usar o fetch global atrás de um backend fino injetado (como createFetchBackend). Adicionar dependência exige justificativa.
Passo 4
A lógica depende da hora atual. E aí?
clique para virar
Injete um Clock (epoch ms): produção passa o relógio real, o teste passa um fixo. Nada de Date.now() — a VM rejeita.
Passo 8
Quando um subsistema está "pronto"?
clique para virar
Quando typecheck && build && test está verde e a contagem total de testes subiu ou ficou estável. Sem testes ou contagem caindo = não pronto.
13

Confusões comuns


"Ports são over-engineering para um subsistema pequeno." Os labs provaram o contrário — o esqueleto de port é o mínimo que compra testabilidade, determinismo e agnosticismo de store ao mesmo tempo. Uma chamada direta a node:fs economiza três linhas e 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 plan-VM rejeita Date.now(), o CI roda os tipos no formato never-throws, o wrapper de teste mata órfãos, e um revisor confere a proveniência. Desvio não é debate de estilo — é um build falhando ou uma mudança rejeitada.
AfirmaçãoConvenção frágil?Imposta?
"Por favor, não use Date.now() no subsistema"simnão
A plan-VM rejeita o módulo com Date.now()nãosim
"Lembre de rodar a suíte com segurança"simnão
O build/test da baseline falha se a suíte quebranãosim
de pedido frágil → regra imposta — a receita vive à direita depende de boa-vontade garantido pela máquina "por favor nãouse Date.now()" "rode a suítecom segurança" plan-VM rejeitaDate.now() baseline falhase a suíte quebra
A mesma intenção pode ser um pedido (frágil) ou uma regra (imposta). A receita prefere a regra — por isso ela funciona entre agentes.
14

Recapitulando


A forma

Uma pasta, três arquivos

types.ts + <nome>.ts + <nome>.test.ts, tudo re-exportado de index.ts. Os sete subsistemas seguem o mesmo molde; o oitavo também.

types.ts <nome>.ts <nome>.test.ts index.ts (nomeado)
1
Passos 1–3

Ports, Zod, Result

Defina os ports (tipos injetados), valide a entrada com safeParse e devolva Result em todo ramo. Nunca lance em público.

unknown safeParse backend okerr
2
Passos 4–6

Determinismo & segurança

Sem globais (Clock injetado), sem dep nova sem justificativa, e a suíte sempre por test:safe com config endurecida por pacote.

Date.now() ✗Clock injetado ✓ npm i … ✗justifique ✓ vitest puro ✗test:safe ✓
3
Passos 7–8

Export, doc, verifique

Re-exporte nomeado com proveniência CLONE/ADAPT, então deixe typecheck && build && test verde com a contagem estável-ou-acima.

index.ts+ proveniência baseline verde testes
4
A lição central

Imposto, não sugerido

A VM, o CI, o wrapper de teste e um revisor impõem a receita. Pular um passo é um build quebrado ou uma mudança rejeitada — não um debate de estilo.

mudança que pula um passo plan-VM CI test:safe revisor REJEITADA
5
Slide 1 / 5 use
15

Verifique


Revisão acumulada

Três perguntas. A pontuação corre conforme você responde — acerte as três e a revisão fecha.

1. Você precisa de um HTTP call num subsistema novo. O que a receita manda fazer primeiro?
Correto: b. Os subsistemas web e media usam o fetch global por meio de um backend fino — sem dependência HTTP. E o backend é injetado, então os 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 de vida do seu subsistema depende da hora atual. A receita exige:
Correto: d. O passo 4 espelha o curator: tempo é efeito colateral, injetado como qualquer outro. Um port Clock torna o ciclo de vida determinístico no teste e replay-safe na 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 de testes "suba ou fique estável" e que a suíte rode via test:safe?
Correto: c. A regra de contagem garante que todo comportamento novo é coberto; o test:safe (lição 25) garante que um teste pendurado não pode travar a CPU por horas. A receita assa as duas coisas na definição de "pronto", para um agente futuro não pular nenhuma em silêncio.
Acertos: 0/3
Você é o aluno e também o auditor: abra qualquer subsistema sob packages/hermes/src/ e percorra os oito passos — "ele tem os três arquivos? valida com Zod? devolve Result? injeta o tempo? evita deps? roda por test:safe? exporta nomeado com proveniência? a suíte está verde?". Se um passo falhar, você achou a falha. A seguir (lição 30): o capstone — onde você junta tudo: o motor, a fusão, o método e a receita num exercício de ponta a ponta.