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.
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.
- Reconhecer a forma única que todo subsistema compartilha:
types.ts+<name>.ts+<name>.test.ts, exports nomeados desrc/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.
types · impl · test)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 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 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".
| Passo | Faça | Porque (invariante / ADR) |
|---|---|---|
| 1 | Defina 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) |
| 2 | Valide toda entrada com Zod safeParse na fronteira | Entradas podem ser saída não confiável de modelo/rede (ADR-0011) |
| 3 | Retorne Result<T, Error> de toda função falível; nunca lance na fronteira pública | A cintura estreita / nunca-lança (ADR-0009) |
| 4 | Sem Date.now()/new Date()/Math.random() — injete um Clock/fábrica de ids | Determinismo & replay, invariante 3 |
| 5 | Não adicione nova dependência de runtime sem justificativa | "Rules for safe changes" (CLAUDE.md) |
| 6 | Adicione o vitest.config.ts por pacote endurecido + rode via test:safe | Segurança de testes anti-órfão (Lição 25) |
| 7 | Exporte símbolos nomeados de src/index.ts; documente a proveniência CLONE/ADAPT + fontes | Descobribilidade + clean-room (ADR-0011 §4) |
| 8 | Mantenha a suíte verde; a contagem total de testes deve aumentar ou ficar estável | Toda 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.
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ó.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:
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:
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.
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?
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.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."
install — ele fica. Por isso a regra inverte o ônus: a dependência precisa se justificar, não o contrário.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.
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.
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
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 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:
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.
Confusões comuns
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.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.
- Pasta
packages/hermes/src/<name>/comtypes.ts+<name>.ts+<name>.test.ts - Toda entrada validada com Zod
safeParsena 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) — semnode:fs, semDate.now(), semMath.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.tscom proveniência + fontes pnpm -r typecheck && pnpm -r build && pnpm -w testverde; 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.
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).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.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.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().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.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).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.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).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.
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.
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.
As peças que esta receita amarra — cada uma é uma lição que abre uma face do mesmo caminho:
Result e nada de globais.vitest.config.ts endurecido por pacote + test:safe são o piso de como toda a suíte roda.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 só 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)
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 já 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
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.- Clone e entre no monorepo:
git clone <repo> alembic && cd alembic, depoispnpm install. O que observar: o workspace resolve os pacotes@alembic/*sem erro. - 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 ovitest.config.tspor pacote (passo 6). - Itere no curto:
pnpm --filter @alembic/<new> test. O que observar na saída: o arquivo<name>.test.tscom os casos em verde e nenhuma menção a diretório temporário — os fakes rodam em memória. - 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. - Feche com a baseline:
pnpm -r typecheck && pnpm -r build && pnpm -w test. O que observar:exit 0e 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:
Fixe a receita (flashcards)
Clique pra virar. Tente lembrar a resposta antes de virar — recuperação ativa fixa mais que reler.
types.ts (Zod + portas), <name>.ts (impl sobre portas) e <name>.test.ts (fakes por porta). Tudo re-exportado de src/index.ts.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).fetch via createFetchBackend; skills escreveu seu parser. Default = 0 deps; furar exige justificativa.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.
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.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.test:safe?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.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.Clock no teste?", "Quando uma nova dependência se justifica?". É só dizer.