In un SaaS di generazione di immagini ho cancellato .prettierrc, eslint.config.mjs e sette dipendenze da package.json nello stesso commit. Il lockfile ha perso 545 righe. Il giorno dopo ho fatto lo stesso in un altro progetto, e poi in un altro ancora. Alla quinta volta non stavo più valutando niente: aprivo il repo, toglievo ESLint e Prettier e scrivevo biome.json.
Quella non è più una preferenza. È un criterio, ed è meglio saperlo difendere.
Cosa mi ha spinto la prima volta
Non è stata la velocità. È stato l'albero delle dipendenze. La configurazione che ho tolto era questa:
const compat = new FlatCompat({ baseDirectory: __dirname });
export default [...compat.extends("next/core-web-vitals", "next/typescript", "prettier")];
Un FlatCompat perché la flat config capisse le configurazioni vecchie. Un eslint-config-prettier il cui unico lavoro è spegnere le regole di ESLint che vanno in conflitto con Prettier. Un eslint-plugin-tailwindcss e un prettier-plugin-tailwindcss che fanno cose simili da due lati diversi. Sei pacchetti la cui ragione di esistere è che gli altri strumenti non si pestino i piedi.
Biome fa formattazione e lint nello stesso binario. Quel problema sparisce perché non ci sono più due strumenti da coordinare.
Cosa si rompe senza che nessuno ti avvisi
La migrazione non è cancellare file e tirare dritto. È la passata di formattazione subito dopo: 78 file toccati in un commit, in cui non puoi rivedere niente perché sono tutte virgolette e indentazioni. Si fa e si accetta.
Il problema vero arriva quando accendi il linter davvero. In un bot di promemoria su Telegram, la prima passata con le regole raccomandate ha dato 60 errori nel sito. Quasi nessuno era rumore:
- Label non associate. Campi del login senza
htmlFor/id. ESLint con la config di Next non me l'aveva mai detto. - SVG decorativi. La regola non riconosce la forma abbreviata di
aria-hidden; serve unaria-hidden="true"esplicito. - Collisione di nomi. Un tipo
TaskListimportato che andava in conflitto con un componente dallo stesso nome. Compilava perfettamente ed era sbagliato. - Key basate sull'indice. In un componente di testo rotante, la key di ogni carattere era l'indice del
map. La soluzione non è stata silenziare la regola: è stata precalcolare l'offset assoluto nel memo per avere una key stabile.
È questo il punto interessante. Dei 60, la maggior parte erano errori veri che stavano lì da mesi. Il resto si è risolto con soppressioni documentate: un effetto a esecuzione singola, loop di scheletri a dimensione fissa, un !important che sovrascrive react-remove-scroll-bar, <img> di avatar da OAuth.
Un biome-ignore senza commento che spiega il perché è debito. Con il commento è una decisione.
La velocità conta per dove ti permette di mettere il lint
È la cosa che si ripete di più nei confronti e quella spiegata peggio. Il numero non conta. Conta dove puoi attaccare il comando quando ci mette poco.
Con ESLint il lint viveva in CI, perché metterlo nella build locale voleva dire condannarti ad aspettare. Con Biome lo script è diventato così:
"build": "bun run lint && next build",
"lint": "biome check .",
"lint:fix": "biome check --write ."
Il lint davanti alla build. Se è rosso, non compili. Quella riga è tutta la differenza: l'errore smette di scoprirsi quindici minuti dopo su un runner e si scopre prima che la build parta. Con uno strumento lento quella riga è insopportabile e finisci per toglierla.
Cosa perdi
Due cose, e vale la pena nominarle.
La prima è l'ecosistema di plugin. Se dipendi da un plugin di ESLint molto specifico, non c'è e probabilmente non ci sarà. L'ho verificato togliendo eslint-plugin-tailwindcss: l'ordine delle classi lo risolve il formattatore di Tailwind, non il linter, quindi non mi è mancato. Ma è un caso fortunato, non una regola generale.
La seconda è più stupida e dà più fastidio: la configurazione di default non è la stessa ovunque. Un progetto è uscito con "indentStyle": "tab" e un altro con "indentStyle": "space" e "indentWidth": 2. Settimane dopo ho dovuto fare un commit il cui unico contenuto era cambiare tab in spazi in biome.json perché tutto fosse uguale. Fissa lo $schema con la versione esatta e copia lo stesso biome.json tra i progetti. La comodità di questo strumento è che non ha quasi configurazione; approfittane e tienine una sola.
Il mio stack di qualità oggi
Biome, TypeScript in modalità strict, test e knip per il codice morto. Poco altro.
Knip è il pezzo che usano in meno e quello che sorprende di più. Una passata sul bot di Telegram ha cancellato 19 file morti e 21 dipendenze inutilizzate: sottopacchetti @radix-ui/* ridondanti con il meta-pacchetto, @react-three/fiber e three interi, date-fns e eslint, che era ancora installato mesi dopo averlo tolto. Salta fuori anche un intero cluster di azioni e casi d'uso che non chiamava più nessuno.
Ma knip resta fuori dalla CI, di proposito. In un repo grande la prima passata ha dato 107 rilevamenti preesistenti: agganciarlo al gate lo mette in rosso dal giorno uno e finisci per ignorare il rosso. Va usato come strumento manuale, ogni qualche mese, con la potatura in un commit a sé e verificata con tsc e i test prima del merge.
La configurazione deve essere esplicita sugli entrypoint o ti cancella mezza applicazione. In un monorepo con Next bisogna dichiarargli a mano quali file sono punti d'ingresso:
"entry": ["app/**/{page,layout,route,sitemap,robots,icon}.{ts,tsx}"],
"ignore": ["components/ui/**"],
"ignoreDependencies": ["sharp", "tailwindcss", "tw-animate-css"]
I componenti generati da shadcn vanno in ignore perché ce ne sono sempre più di quanti ne usi ed è normale. sharp e tailwindcss vanno in ignoreDependencies perché non li importa nessuno, si usano dalla build.
Un linter lento è un linter che finisce in un job a parte che non guarda nessuno. Un linter veloce entra nella build e diventa il pavimento del progetto.
