AkiraAkira.dev
3 min de leitura

Partir o código antes que a release o faça

Um backport de segurança podia ter roubado o @latest a toda a gente. A função que devia impedir isso recebia o argumento certo e nunca o lia.

também em EN FR

Um pacote está na versão 2.1.0. Aparece uma falha de segurança na linha v1, que ainda tem gente em produção, e a correção sai como v1.3.2. Coisa banal, faz-se todos os dias.

A partir desse momento, quem escreve npm install recebe a v1. Quem tem @latest fixado num Dockerfile recebe a v1. Quem depende da v2 e reinstala fica com um pacote sem metade da superfície e com um peer range diferente. O workflow acaba verde. A página da release está impecável. O único sintoma é o build de outras pessoas a rebentar contra uma API de dois majors atrás, e são elas que dão por isso primeiro.

Isto estava no akira-io/ui até à release de hoje:

// scripts/release-dist-tag.mjs
export function resolveDistTag(version, currentLatest = '') {
    const match = VERSION_PATTERN.exec(version);
    if (!match) {
        throw new Error(`${version} is not a valid semantic version`);
    }

    return match[4] ?? 'latest';
}

currentLatest é um parâmetro. O workflow de release vai buscá-lo ao registry e passa-o. A função nunca o lê. Toda a versão estável fica latest, sem condição nenhuma.

Nenhum teste apanhava isto, e havia testes para esta função.

O que a suíte verde não estava a dizer

O método que expôs o resto foi banal: pegar naquilo que um teste diz guardar e parti-lo de propósito.

Reescrevi onze props declaradas em src/blocks/login-form/parts.tsx para cada uma ignorar o valor recebido e cair no default. A suíte inteira passou. Onze props documentadas, exportadas, publicadas. Duas delas, id e name, são o caminho documentado para pôr dois formulários de login na mesma página, e nada as exercitava com um valor diferente do default.

O caso mais desconfortável foi este, que saiu na v2.2.0:

// src/blocks/login-form/index.test.ts
it('re-exports LoginFormStatusProps alongside the other part prop types', () => {
    const props: LoginFormStatusProps = { message: 'ok' };

    expect(props.message).toBe('ok');
});

O vitest transpila TypeScript, não verifica tipos. A anotação desaparece antes de o teste correr, portanto o que sobra é um objeto com a chave message a ser interrogado sobre se tem a chave message. Apagar o re-export que o teste existe para guardar deixa-o verde na mesma. Só falha sob tsc --noEmit, e nada corria tsc --noEmit.

A objeção óbvia

Mutation testing não é ideia nova. O Stryker faz isto em escala, gera os mutantes, pontua a suíte e não precisa de ninguém a escolher onze props à mão.

Verdade, e passa a fazer parte do plano. Mas a ferramenta não era a peça em falta. As props estavam declaradas e o ficheiro estava ali para ser lido. O que faltava era recusar aceitar um run verde como prova de que aquilo funcionava, e essa desconfiança nenhuma ferramenta instala. A ferramenta escala o método depois de existir método.

A versão barata custa uma tarde. Escolher aquilo que um teste diz proteger, partir, correr o teste. Se passa, apareceu um buraco. Se falha, apareceu um teste. Qualquer um dos dois vale mais do que o run que ninguém questionou.

O que ficou

A suíte que sobreviveu às onze mutações falha agora contra todas elas. O resolveDistTag lê o argumento que aceita, e um backport de major inferior recebe uma tag própria em vez da que toda a gente instala por omissão. Há um workflow a correr testes, typecheck e formatação em cada pull request, que é o que dá sentido a uma asserção que só o compilador consegue avaliar.

Nada disto é engenhoso. Existe porque deixou de bastar ler verde.

O único run que ensina alguma coisa é aquele que falha à primeira.

partilhar