Localmente ottimo, globalmente alla deriva
Uno dei miei colleghi in Spatie, nientemeno che il famigerato Freek, un forte ingegnere back-end (non lo dico per fargli un complimento) con meno esperienza front-end (scusa), voleva sapere fin dove si potesse spingere l'AI a scrivere l'intero front-end di un prodotto reale. Un esperimento audace o un azzardo imprudente? Non importa! "There There" è una piattaforma di customer support con ticket, canali, agenti AI, un widget di chat incorporabile che viene distribuito sui siti dei clienti e molto altro ancora.
«Potresti dare un'occhiata al front-end e valutarlo?» chiese. «È davvero pronto per la produzione?»
Così, per due giorni, ho fatto proprio questo. TL;DR: il codice è sorprendentemente buono a livello locale, e sempre più incoerente a livello globale. È un risultato interessante, dato che il prodotto stesso funziona davvero bene. Lo stiamo usando internamente mentre scrivo. Quindi, volevo parlare di come ho gestito questo controllo di qualità del codice e di come ho usato Claude per darmi una mano.
L'AI non può fare la revisione
Prima di aprire un solo file, ho deciso una cosa: avrei potuto chiedere all'AI di revisionare il codebase, pubblicare l'output e accomodarmi tranquillamente per due giorni a guardare Netflix. Ovviamente non l'ho fatto. Avrebbe prodotto una revisione plausibile. Non avrebbe prodotto una revisione reale. E mi sarei sentito davvero in colpa a guardare Netflix ...
Una revisione reale decide «questo è un vero bug», «questa è una questione di preferenze», «questo va bene», «questo non va assolutamente bene». L'AI è eccellente nel fare elenchi. Non è altrettanto brava a prendersi la responsabilità di un'opinione.
Non iniziare con l'AI. Volevo prima costruire il mio modello mentale. Ciò significava capire domini, confini, struttura delle cartelle. Leggere un po' di codice. Tracciare un flusso o due. Senza quella base, mi limiterei a mettere un timbro di «sì» su ogni prompt che tornava indietro.
Quando entra in gioco l'AI, usala in modo meccanico. Come grep dopato: conta, elenca, trova; cose con risposte che posso controllare a occhio. E come generatore di candidati: segnala tutto ciò che potrebbe essere un problema in base ai miei standard; deciderò io quali lo sono davvero.
Così le prime ore sono stato solo io, l'albero delle cartelle di There There e una manciata di file. Ancora niente AI. Solo cercare di capire cosa fossero davvero i domini «ticket», «canali», «brains», «recaps», come si relazionassero tra loro e dove si trovassero i confini. Continuavo a chiedermi «Dove metterei le cose se stessi scrivendo questo io stesso?» Avevo bisogno del mio quadro di riferimento prima che qualcosa potesse spingermi verso una conclusione.
Poi è arrivato il momento. Ho fatto entrare l'AI. Come contatore e come generatore. Per il generatore, ho scritto un lungo prompt chiedendole di trovare bug, anti-pattern di React, problemi di performance, preoccupazioni di sicurezza, pezzi mancanti, incoerenze. Qualsiasi cosa riuscisse a giustificare. Ha prodotto un catalogo di ~600 righe con tag di gravità e numeri di riga. Più di quanto avrei potuto far emergere a mano. È stato davvero impressionante.
Poi è iniziato il vero lavoro. Passare in rassegna quel catalogo e validare ogni voce. A essere onesti, è stata un po' noiosa, ma mi ha aiutato a capire ancora meglio il codebase. Alcune erano veri bug. Alcune erano veri trade-off. Alcune erano preferenze di stile con addosso un tag di gravità. Alcune erano l'AI che individuava un outlier e lo descriveva come un pattern. Quella validazione è la revisione. Tutto il resto sono solo elenchi. Spoiler: non ero ancora arrivato alla fine dell'elenco. C'erano parecchio codice e controlli di gravità da validare in due giorni. Non riuscivo a trovare una soluzione per ogni problema in tempo, perché il codice era intrecciato tra i domini. La conclusione stava già prendendo forma: a livello globale, c'erano incoerenze.
Passaggio 1: architettura
Dovevo prima allargare la visuale. Guardare la struttura delle cartelle, la direzione degli import e dove vivevano i domini.
La struttura è composta da un paio di cartelle principali: common/, modules/, pages/, con una widget/ separata per il widget di chat rivolto al cliente che viene distribuito su siti esterni. Le primitive UI provengono da shadcn/ui. Le cose condivise di livello superiore stanno sotto common/. Il codice per singola funzionalità vive sotto modules/tickets/, modules/chat-agent/ e qualche altra. Le pagine mettono tutto insieme. A essere onesti, è un layout con cui probabilmente inizierei anch'io se qualcuno mi consegnasse un repo vuoto. Non avevo bisogno dell'AI per capire questa struttura. Ma l'ho usata per ricontrollare di non aver tralasciato nulla di ovvio. Finora nessuna sorpresa.
Poi ho aperto alcuni file in common/ e mi sono fermato. AiSetupPrompt.tsx aveva esattamente un consumatore, dentro le impostazioni del workspace. ChannelBadgeList.tsx, lo stesso. Un albero di editor di chat AI sotto common/components/ai-chat/ era usato solo da modules/agent-chat/. Questi non sono common. Sono componenti specifici di una funzionalità che sono finiti in common/ perché nessuno ha detto all'AI di non metterli lì.
Qui è dove l'AI si è guadagnata il suo stipendio. «Per ogni file sotto common/components, elenca ogni file che lo importa.» Pochi minuti dopo avevo la lista, e il pattern era ovvio. Una manciata di file che in realtà apparterrebbero a un modulo se avessimo avuto una regola su dove vanno le cose. Ho riconosciuto la forma perché l'ho fatto anch'io su altri codebase. Il «temporaneo» in common/ diventa permanente più in fretta di quanto chiunque si aspetti.
For every file under common/, list every file outside common/ that imports it. Give me the output as: common/path/File.tsx → [list of importers]. Sort by importer count ascending. Flag any file with 0 or 1 importers. Those are candidates for "this isn't actually common, it's feature code that landed here."
Verify import direction. Find any import where:
- common/ imports from modules/ or pages/
- modules/ imports from pages/
- modules/X imports from modules/Y (list both directions found)
- widget/ imports from anywhere outside widget/ and common/
List every directory under pages/ with more than 20 files. For each, give me: file count, and a one-line guess at whether the contents are (1) thin page orchestration, (2) domain logic that should live in modules/, or (3) mixed. Base the guess on filenames and top-level exportsL'altra direzione era peggiore. pages/settings/workspace/ ha 75 file. Accidenti. Dopo averne aperti alcuni, era chiaro che non si trattava di UI delle impostazioni. Era logica di dominio. Procedure guidate di configurazione dei canali con verifica DNS. Editor di workflow con otto sotto-editor specifici per tipo. Fatturazione con confronto piani e integrazione Stripe. AI brains con gestione delle connessioni MCP. Un intero prodotto fatto di domini, mai estratti in moduli. Vivevano dentro una cartella di pagine perché una cartella di pagine era dove il lavoro era iniziato, e nulla si è mai spostato.
Validare la direzione degli import in sé è stato in realtà pulito. common non si estendeva dentro modules o pages. modules non si estendeva dentro pages. Il widget era completamente isolato. Questo è positivo. Ma nulla di tutto ciò era scritto da nessuna parte, e questo è il punto. Era una convenzione che per caso reggeva ... per ora. Una futura sessione AI che non conosce la convenzione la infrangerà la prima volta che le farà comodo. Regole come queste devono essere scritte. Se lo fossero state, molta della deriva globale si sarebbe potuta evitare, o almeno mantenere a un livello gestibile.
Passaggio 2: componenti e hook
Dopo aver guardato il quadro generale, ho zoomato. Ho aperto pages/tickets/Index.tsx e l'ho letto quasi tutto dall'inizio alla fine. Oltre mille righe. Quando sono arrivato in fondo avevo una sensazione chiara: questa non è una pagina, è un'applicazione che vive dentro una pagina. Poi ho chiesto all'AI dei numeri. È davvero brava in questo: trovare pattern e deviazioni rispetto ai miei standard.
For pages/tickets/Index.tsx, give me:
- useState count
- useRef count
- useEffect count, and for each useEffect: dependency array, and a one line read on whether it looks like a genuine side effect, derived state that should be useMemo or just an inline expression, "sync with prop" that's papering over a parent issue
- useCallback / useMemo count, and how many are passed to memoized children vs. just defensively wrapped
- Total prop count for most-propped components rendered in the file
- Any context value object literal, count its fields
Just give me the numbers. I'll decide what's bad.
Count props for these components specifically: Composer, CommandPalette, AgentChatContext (the context value, not the provider). For each give me total props, how many are onX callbacks, how many are booleans, how many are domain objects.Le risposte sono arrivate: 29 chiamate useState/useRef in quel singolo file. 14 coppie useCallback/useEffect. Un Composer con 17 prop, 8 delle quali callback onX. Un CommandPalette con 14 prop, 11 delle quali comandi. Un AgentChatContext con 22 campi nel suo valore di contesto. Ho fatto contare due volte all'AI, giusto per essere sicuro, e tristemente non sono diventati più piccoli. Non sono numeri che avrei calcolato a mano, ma hanno trasformato un vago «sembra troppo grande» a prima vista in un segnale forte. God-component che fingono di essere componenti. Ogni nuovo comando o esito allarga l'interfaccia. «Morte per mille callback». L'AI mi ha aiutato a ridurli e a basare le mie conclusioni su fatti invece che su una sensazione.
Ecco dove il catalogo generato dall'AI ha iniziato a ripagare più direttamente. Alcuni risultati a cui avrei impiegato giorni per arrivare da solo. Dovevo comunque validarli, ma sono rimasto colpito da ciò che ha fatto emergere una volta che sapeva cosa cercare:
useDraftManagementcalpesta l'input dell'utente. L'effetto di ripristino dipende da[ticketUlid, mode, replyTemplate].replyTemplateè una prop oggetto semplice proveniente dalla pagina. Qualsiasi re-render che produca una nuova identità dell'oggetto riesegue l'effetto, rilegge localStorage e sovrascrive qualunque cosa l'utente stia digitando. In produzione, questo mangia silenziosamente il lavoro in corso sulle bozze. Vero bug.useChannelregistra callback obsolete. Gli handler sono mantenuti in una ref, ma la ref viene iterata solo una volta, al momento della sottoscrizione. Echo mantiene qualunque callback gli sia stata passata per prima. I render successivi aggiornano la ref e nessuno se ne accorge. Vero bug.- Il memo di gruppo di
AgentChatHistorynon si ricalcola mai.useMemo(() => groupChats(filteredChats, currentTicketRef.current?.id ?? null), [filteredChats, currentTicketRef]).currentTicketRefè unMutableRefObject, quindi la sua identità è stabile per l'intera vita del componente. Il memo è fissato al primo render per sempre. «Relativo a questo ticket» mostra il ticket che era attivo al mount. Bug silenzioso. forwardRefin due file, ref come prop semplice ovunque altrove.MentionList.tsxePlaceholderSuggestion.tsxusano ancoraforwardRef<Handle, Props>. Tutto il resto usaref?: Ref<T>come prop semplice, che è come React 19 vuole che tu lo scriva. Due file sono un lancio di moneta che aspetta di accadere la prossima volta che l'AI ne tocca uno.
E uno in cui ho ribaltato il catalogo.
Segnalava un useState<string | undefined>(undefined) in TicketViewsSidebar.tsx come «un'incoerenza con la convenzione useState<T | null>(null) usata in tutto il codebase». Tecnicamente vero: 30 usi di null, uno di undefined. Ma un caso contro trenta non è un pattern, è un outlier. Un essere umano guarda entrambi e reagisce diversamente. L'AI lancia lo stesso flag per entrambi, perché tutto ciò che vede è «incoerenza presente». Non ha alcun senso di quanta incoerenza ci sia, o se la convenzione sia già stabilita.
Localmente buono, globalmente alla deriva
Alla fine di due passaggi, era emerso un pattern più chiaro.
Individualmente, la maggior parte dei file in questo codebase è buona. Tailwind coerente tramite cn(), non una singola interpolazione di classe con template literal da nessuna parte. Dark mode gestita tramite token semantici (text-muted-foreground, bg-card), non override dark: sparsi ovunque. Componenti funzionali, non const Foo = (props) => (...). Naming uniforme. shadcn/ui come unica fonte di primitive. displayName impostato solo dove conta davvero. Apri un file a caso in isolamento e per lo più annuiresti. A essere onesti: l'AI scrive bene un singolo file. È solo che a volte, i file successivi potrebbero rompere quella serie.
La deriva si mostra nel momento in cui i file devono interagire.
Tre modi per renderizzare uno stato vuoto. Un file li usa tutti e tre contemporaneamente. Due copie indipendenti di formatRelativeTime con soglie diverse e casing diverso («Just now» vs «just now»). Il token di design text-destructive nella maggior parte dei posti, text-red-500 hardcoded in altri. Errori di form tramite il wrapper <FormField> in ~12 file, e copia-incolla manuale di <p className="text-sm text-destructive"> in oltre 15 altri posti. Stringhe di stato come 'open' | 'closed' | 'spam' | 'waiting' inline 98 volte nel modulo tickets. Un'unione WorkspaceMemberRole corrispondente vive già nei tipi generati, pronta per essere importata. Perché l'AI non l'ha usata? Perché nessuno le ha detto di farlo.
Nessuna di queste è «l'AI ha fatto un errore». Ogni scelta è ragionevole in isolamento. Il problema è che non esiste un quadro di riferimento condiviso tra le sessioni. Ogni decisione viene presa da zero, e N decisioni dopo hai N varianti. La coerenza locale non è la stessa cosa della coerenza globale.
Questa è la cosa che mi ha convinto che questa revisione valeva la pena di essere trasformata in un documento.
Il ruleset
Ciò che è uscito da due passaggi è un piccolo insieme di regole che committerei nel repo come README.md. Non una style guide. Un ruleset. Il tipo di cosa che un linter può far rispettare:
I tre livelli.
common/: usato da più di due moduli e feature-agnostic nella sua API pubblica. Nessun tipo di dominio nelle prop o nelle forme di ritorno.modules/X/: di proprietà del dominio. Componenti, hook, helper, tipi per la funzionalità X.pages/X/: sottile orchestratore più qualsiasi layout genuinamente specifico della pagina.
Direzione degli import: common ← modules ← pages. common non importa nulla a monte. modules importa solo da common. pages importa da entrambi. Tra moduli, una sola direzione (domini di livello inferiore come users e teams alimentano quelli di livello superiore come tickets, mai il contrario). Potremmo farlo rispettare tramite no-restricted-imports di ESLint. Le regole meccaniche non possono essere infrante silenziosamente da una futura sessione.
Un hook per file. Un componente per file. Le eccezioni non valgono mai la deriva che creano.
forwardRef è vietato. React 19 tratta ref come una prop semplice. Usa quello.
Proprietà dello stato, per scope.
- I dati server passano attraverso le prop di Inertia.
- La verità server condivisa che si aggiorna a metà sessione passa attraverso
useSharedData. - Lo stato che appartiene all'URL (ticket selezionato, filtri, ricerca) passa attraverso parametri URL tramite un hook tipizzato.
- Lo stato UI locale (dialog, hover, input) è
useState. - Lo stato cross-component all'interno di una funzionalità passa attraverso un contesto di funzionalità che lancia un errore quando usato fuori dal suo provider.
In generale: lo stato vive il più lontano possibile dal DOM, in questo ordine: URL > condiviso > contesto > locale.
Gli errori di form passano attraverso <FormField>. Niente <p> fatti a mano.
La lista è deliberatamente breve. Non voglio «token out» l'AI con troppe regole. Ma con questa lista in atto, la maggior parte di ciò che ho trovato in due passaggi sarebbe stato catturato al momento della scrittura. Dato un ruleset nel suo contesto, l'AI segue le regole in modo piuttosto coerente. Questa è la parte che trovo più incoraggiante dell'intero esercizio.
Cosa ho effettivamente imparato
All'inizio, appena ho sentito che il front-end era interamente generato dall'AI, mi aspettavo mezzo disastro. Ho provato a mantenere una mente aperta che potesse essere solo una lista di problemi minori. Ho trovato qualcosa di più interessante. Questo codebase è localmente più coerente di quanto avessi il diritto di aspettarmi. Il naming è uniforme. cn() è applicato in modo coerente. La dark mode copre la superficie. La maggior parte dei componenti è focalizzata, moderna e effettivamente funzionante.
E globalmente, senza nulla a cui ancorarsi, deriva lontano da quel livello coerente che trovi localmente. Un singolo file viene scritto nella forma di «qualunque cosa sembrasse giusta questa sessione». Mille file di «qualunque cosa sembrasse giusta» è come finisci con tre varianti di stato vuoto e due copie dello stesso helper. È come quando uno sviluppatore lavora abbastanza a lungo su un codebase: alla fine sviluppa un gusto per ciò che è 'giusto' lì. L'AI non lo fa mai. Ogni sessione è un nuovo inizio senza memoria di ciò che è stato deciso ieri. Dev'essere terribile: un eterno primo giorno di lavoro.
Ciò che voglio portare via da questa revisione non è «Non usare l'AI sul front-end e guardati Netflix». È più sulla linea di «L'AI è uno strumento; se usi l'AI per il front-end, scrivi prima il ruleset, committalo nel repo, forniscilo di nuovo in ogni sessione. E passaci attraverso con l'AI invece di lasciarle prendere le redini».
Senza AI, la revisione avrebbe richiesto molto più tempo, e non avrei circoscritto i problemi così rapidamente. Usare l'AI per i compiti ripetibili, pesanti e onestamente piuttosto noiosi ha amplificato i miei progressi; non li ha sostituiti. La revisione si è trasformata in un ruleset, e il ruleset è ciò che impedirà ai prossimi centinaia di file di ripetere la deriva del primo centinaio in un buco nero di incoerenza (si spera).
Fonte originale:
https://spatie.be/blog/locally-great-globally-driftingAltri articoli recenti
Laravel AI 1.1 e 1.2: Agent Skills, Cohere e structured output più solidi
Le release 1.1.0 e 1.2.0 di laravel/ai portano gli Agent Skills, l'approvazione obbligatoria per i tool built-in e nuovi provider come Cohere e Laya self-hosted. Sul fronte qualità arrivano i fix allo structured output per Bedrock e il supporto alle provider options. Aggiornati anche i modelli di default, con Claude 4.7+ che ignora i sampling params.
Laravel 13.35.0: scheduler più controllabile, code più flessibili e response Markdown
Laravel 13.35.0 porta Schedule::alwaysOnOneServer(), il supporto a schedule:interrupt per i comandi di lunga durata, --memory in percentuale per queue:work e il nuovo metodo markdown() sulla response factory. Tante piccole migliorie anche a test, view compilate in parallelo e dischi local.
BashBox: un interprete Bash in sandbox per agenti AI in PHP
BashBox è un interprete bash scritto in puro PHP che permette di eseguire gli script generati dagli agenti AI senza mai ricorrere a proc_open, exec o shell_exec. Ogni comando è una classe PHP che lavora su un filesystem virtuale, con limiti di esecuzione, quote disco e accesso di rete configurabili. Ideale per chi sviluppa agenti con il Laravel AI SDK o altre librerie simili.