Lição 29 · Fusão v3 · O operador e as gotchas ← índice
Alembic × Hermes — O Curso de Fusão v3 · Fechamento (26–30)

O operador e as gotchas

Nove armadilhas que custaram caro de verdade — e o que TODAS têm em comum: elas falham em SILÊNCIO. Residue zero sem aviso, símlink que some, teste que "passa" sem rodar, empate resolvido por ordem escondida. Esta lição diagrama cada erro e o seu fix, e fecha com a rotina que transforma o operador na última linha de defesa.

1

Por que erro silencioso é o pior inimigo

O motor é fail-closed nas BORDAS: argv inválido morre em exit 2, config errada vira err nomeado, gasto sem dupla trava vira preview. As gotchas desta lição vivem no espaço que sobra — onde o comportamento é TECNICAMENTE correto (a função fez o que o tipo promete) mas a EXPECTATIVA do operador estava errada. walkCorpus não "falha" ao ignorar um símlink; pickCheapestForTier não "erra" ao manter o primeiro no empate. O contrato foi cumprido; a surpresa é sua. Por isso cada gotcha abaixo tem o mesmo formato: o erro COMO ELE APARECE, o mecanismo com citação, e o fix como comando ou regra.

Pense como… as placas afiadas de uma oficina de marceneiro: cada uma marca o lugar EXATO onde alguém já perdeu um dedo. Onde a analogia quebra: aqui a serra nem faz barulho — a placa é a única testemunha de que o perigo existe.

2

As nove gotchas, erro → fix

① family-prefix: a família vem do 1º segmento do path

classifyFamily (packages/etl/src/priors.ts:70–76) lê o PRIMEIRO segmento do path relativo ao corpus; segmento não reconhecido ⇒ Unknown. Aponte o distill na RAIZ de uma família (ex.: …/Resources/Bookmarks) e os primeiros segmentos viram items/, index/… ⇒ tudo Unknownresidue 0, silencioso. O fix: aponte no PAI (…/Resources), para o 1º segmento SER o nome da família; para escopar UMA família, o truque cpio — copiar só os package.jsonl daquela família preservando a árvore.

ERRO distill …/Resources/Bookmarks 1º segmento = items/ ⇒ Unknown (priors.ts:70–76) ⇒ residue 0, SEM aviso nenhum FIX distill …/Resources (o PAI) 1º segmento = Bookmarks ⇒ família certa, prior certo 1 família só? cpio dos package.jsonl preservando a árvore

② símlinks invisíveis: walkCorpus e SkillStore não os seguem

O readDir do FsPort mapeia cada Dirent para { isDirectory, isFile } (packages/etl/src/fs-port.ts:139–146) — e um símlink responde false para os dois. Resultado: walkCorpus (pipeline.ts:127–131) pula o símlink sem registrar nada, e o SkillStore idem (packages/hermes/src/skills/skill-store.ts:89: if (!entry.isDirectory) continue;) — skills de plugin symlinkadas não entram no import. Nenhum erro, nenhum warning: o item simplesmente NÃO EXISTE para o walker.

ERRO skill@ → ~/.claude/skills/skill Dirent: isDirectory=false E isFile=false ⇒ walker pula; item não existe p/ ele (fs-port.ts:139–146) FIX materialize antes: cp -RL / rsync -L só dirs REAIS com SKILL.md entram (skill-store.ts:89) e confira a contagem pós-walk contra o esperado

③ empate de custo: a ordem de declaração decide

pickCheapestForTier (packages/contracts/src/registry.ts:244–257) reduz com entryCost < bestCost ? entry : cheapest — no EMPATE, o incumbente (o declarado primeiro) vence. No T2, dois modelos custam os mesmos 0.0005/1k somados; quando o primeiro declarado era o deepseek-v4-pro (AccessDenied no gateway), a rota T2 inteira caía num modelo inutilizável. O fix do PR #157 não mudou a função: mudou a ORDEM — gemini-3.5-flash declarado antes. Ordem de declaração no registry é decisão de produto, não detalhe.

ERRO T2: 0.0005 == 0.0005 (empate) reduce mantém o 1º declarado (registry.ts:244–257) ⇒ rota cai no deepseek-v4-pro: AccessDenied FIX PR #157: ordem de declaração é decisão gemini-3.5-flash declarado ANTES ⇒ empate cai nele regra: modelo utilizável primeiro; empate documentado

④ o filter-test enganoso: "passou" sem rodar nada

pnpm --filter <pkg> test sai com código 0 quando o pacote NÃO TEM script test — e há instância viva: @alembic/loop-engineering não declara o script (prove: grep '"test"' packages/loop-engineering/package.json → vazio), então o filter "passa" tendo rodado ZERO dos seus 8 testes. Eles só rodam pelo glob do raiz (vitest.workspace.ts: packages/*/src/**/*.test.ts). E um pacote que queira --filter test confiável precisa do próprio vitest.config.ts com setupFiles em caminho absoluto — 17 pacotes têm o seu (prove: ls packages/*/vitest.config.ts | wc -l).

ERRO pnpm --filter X test → exit 0 ✓ …mas o pacote não tem script "test" ⇒ 0 testes rodaram; o verde é vazio FIX conte os testes no output, nunca só o exit suíte raiz: pnpm -w test (glob pega todo *.test.ts) pacote novo: vitest.config.ts próprio (setupFiles absoluto)

⑤ órfãos tinypool: o teste que trava a CPU por horas

Dois ingredientes: um teste pendurado sem teardown E o pai morto sem matar a árvore. Os workers tinypool do vitest reparentam para o PID 1 e pinam um core (~90% CPU) por horas. O fix é estrutural e já é o BASELINE da casa: scripts/safe-test.mjs roda a suíte em process group PRÓPRIO (detached: true ⇒ setsid), aplica hard-timeout, mata o grupo INTEIRO (kill(-pid)) e varre sobras (pkill -9 -f vitest); hard-timeout sai com 124. Por isso o baseline é node scripts/safe-test.mjs pnpm -w test — nunca o vitest pelado num loop.

fusao · 25-test-safety-engineering · s0
ERRO kill(pid) só no vitest main forks tinypool reparentam → PID 1 ⇒ core pinado por HORAS, invisível no CI FIX node scripts/safe-test.mjs pnpm -w test detached ⇒ grupo próprio · kill(-pid) no grupo TODO sweep pkill vitest · hard-timeout ⇒ exit 124

⑥ branch nova com dep workspace ⇒ pnpm install

Você faz checkout de uma branch que ADICIONA uma dependência workspace:*. O node_modules local não tem o símlink novo: tsc falha com Cannot find module '@alembic/X' — enquanto o vitest PASSA (resolve os fontes pelo glob). A dupla mentira: o typecheck vermelho parece bug do código, e o teste verde parece absolvição. Fix mecânico: pnpm install após o checkout, e pnpm -r build para os dependentes enxergarem os .d.ts novos. CI é imune (instala sempre do zero) — a armadilha é só local.

ERRO tsc: Cannot find module '@alembic/X' …e o vitest PASSA (resolve fontes direto) símlink do workspace não existe pós-checkout FIX pnpm install (após TODO checkout de branch) depois pnpm -r build (dependentes veem os .d.ts) CI é imune — a armadilha é do ambiente local

⑦ contagens à mão: NUNCA

A gotcha mais barata de evitar e a mais recorrente do histórico: escrever "o CLI tem N comandos" contando no olho. O digest #1 do failure-historian elegeu o drift de contagem como pior ofensor; a resposta foi scripts/derive-counts.mjs (PR #158), e a primeira execução JÁ pegou drift vivo — docs diziam 32; a fonte (USAGE) dizia 33/47. Regra da casa desde então: número de superfície se deriva da fonte por script; contagem manual em doc é bug esperando leitor.

ERRO docs: "32 comandos" (contado no olho) a superfície real mudou; o doc ficou parado ⇒ todo leitor herda o número errado FIX scripts/derive-counts.mjs (PR #158) deriva do USAGE exportado; 1ª execução pegou 32→33/47 lei: número de superfície NUNCA é digitado

⑧ squash-illusion: o git que engana sobre merge

Squash-merge cria um commit NOVO em main, com SHA diferente dos commits da branch. Consequência: git merge-base --is-ancestor e git cherry respondem como se a branch NÃO tivesse sido mergeada — e um operador apressado "re-mergeia" trabalho já entregue, ou conclui que algo se perdeu. A única testemunha confiável é o log de main: procure o ASSUNTO do commit, não o SHA.

ERRO git merge-base --is-ancestor → "não" squash criou SHA novo em main; o antigo não é ancestral ⇒ re-merge acidental / falso "trabalho perdido" FIX git log --oneline main | grep "<assunto>" a verdade pós-squash é o LOG DE MAIN, por assunto is-ancestor/cherry só valem em merge sem squash

⑨ fabricação sem tools: o turno que "varre" sem varrer

A gotcha mais perigosa porque parece SUCESSO. O turno do employee (employee run --online e automation run) NÃO tem tool-loop: o modelo recebe prompt e devolve texto — ele não executa ferramenta nenhuma. Peça "varra o diretório X e liste os arquivos" e a resposta virá com uma varredura fabricada, plausível e falsa (provado na fronteira). O fix até a perna tool-loop existir: dados VERIFICADOS vão NO PROMPT (você varre, o modelo raciocina), e a honestidade estrutural da casa cerca o resto — employee explain etiqueta cada claim como observed/inferred/unknown, e o write-back A3b grava só o goal real + excerpt real da resposta, nunca um "learning" inventado.

ERRO goal: "varra X e liste os arquivos" turno sem tool-loop ⇒ o modelo NÃO varre nada ⇒ lista fabricada, plausível, FALSA (provado) FIX você varre; o resultado VERIFICADO vai no prompt explain: observed/inferred/unknown · A3b: goal+excerpt reais pedir ação de ferramenta espera a perna tool-loop (lição 30)
3

O baseline e a rotina do operador

Contra erro silencioso, ritual explícito. O baseline canônico — o MESMO para humano e agente, sempre com o safe-test na frente da suíte:

pnpm -r typecheck && pnpm -r build && node scripts/safe-test.mjs pnpm -w test

E a rotina que amarra as nove placas num hábito só — cada passo mata uma classe de gotcha:

PassoO que fazerGotchas que mata
1 · Chegargit pull + pnpm install + baseline completo⑥ símlink de workspace · ⑤ órfãos
2 · ApontarConferir o path do corpus (PAI da família) e a contagem pós-walk① family-prefix · ② símlinks
3 · RodarDry-run PRIMEIRO; efeito só com a flag do efeito⑨ fabricação (dados no prompt)
4 · VerificarContar o que rodou no OUTPUT (testes, linhas, records) — nunca só o exit 0④ filter-test · ③ empate escondido
5 · RegistrarNúmeros derivados por script; merge conferido no log de main⑦ contagem à mão · ⑧ squash-illusion
launch-academy · 07-practice-labs · s1
A escada da evidência (a rubrica que o curso inteiro pratica): claim < citação < comando < prova reproduzível. Toda linha desta lição sobe até o degrau 3 — cada gotcha tem o comando que a reproduz ou a linha de código que a explica. O operador que exige o degrau 3 de si mesmo não cai em nenhuma das nove.
Você roda alembic distill ~/Documents/Resources/Bookmarks --offline e o run termina com residue 0, sem erro algum. Qual é a leitura correta?
Correto: classifyFamily (priors.ts:70–76) lê o 1º segmento do path RELATIVO AO CORPUS. Apontando na raiz da família, os primeiros segmentos são as subpastas internas ⇒ Unknown ⇒ residue 0 SILENCIOSO. Fix: aponte no PAI (…/Resources) — e desconfie de todo zero que veio fácil demais.
`pnpm --filter @alembic/loop-engineering test` devolve exit 0. O que você aprendeu sobre os 8 testes do pacote?
Correto: sem script test no package.json (prove com grep '"test"' packages/loop-engineering/package.json), o filter sai 0 sem rodar NADA. Os 8 testes de runtime.test.ts só rodam na suíte raiz (pnpm -w test, glob do vitest.workspace.ts). Verde sem contagem de testes no output é verde vazio.
Camada técnica — comandos copy-paste

Reproduza cada mecanismo desta lição ($0, leitura pura):

# ① a família vem do 1º segmento
sed -n '66,80p' packages/etl/src/priors.ts

# ② símlinks: o Dirent mapeado e os dois walkers que pulam
sed -n '139,146p' packages/etl/src/fs-port.ts
sed -n '127,131p' packages/etl/src/pipeline.ts
sed -n '84,96p'  packages/hermes/src/skills/skill-store.ts

# ③ o empate mantém o primeiro declarado
sed -n '244,257p' packages/contracts/src/registry.ts

# ④ o filter enganoso, ao vivo
grep -n '"test"' packages/loop-engineering/package.json ; echo "exit=$?"
ls packages/*/vitest.config.ts | wc -l

# ⑤ o safe-test por dentro (grupo próprio + sweep + 124)
sed -n '1,50p' scripts/safe-test.mjs

# ⑧ a verdade pós-squash é o log de main
git log --oneline main | grep -i "<assunto do PR>"

# o baseline canônico, sempre
pnpm -r typecheck && pnpm -r build && node scripts/safe-test.mjs pnpm -w test
O que levar desta lição
Pergunta de acompanhamento sugerida: "quais dessas gotchas um gate automático poderia matar de vez?" — repare que ⑤ e ⑦ JÁ viraram script (safe-test, derive-counts): é o motor consertando o próprio operador. Próxima lição: o capstone — o ciclo fechado inteiro, as 7 provas de boundary e, com honestidade, o que ainda falta.