Salta ai contenuti

Intelligence del codice

Control Center offre a un agente quattro modi per capire il codice che vanno oltre la semplice lettura. Rispondono a domande davvero diverse, e il motivo per cui sono quattro e non uno è che nessun singolo meccanismo le risponde tutte.

Meccanismo Risponde a Costi
Grafo del codice Cos’è qui e cosa dipende da esso Una passata di indicizzazione; query economiche
Language server Compila davvero, e che tipo è questo Un processo server per progetto
Ricerca strutturale Dove compare questa FORMA Un’analisi per file
Debugger Cosa era vero in questo momento, in questo frame Un processo in pausa

Il grafo del codice è costruito con tree-sitter: simboli, archi di chiamata, un raggio d’impatto. È veloce, è economico e non sa nulla di tipi. Non può dirti se la chiamata che hai appena scritto compila, se quel campo esiste sull’oggetto o se la tua rinomina ha mancato un re-export.

Senza un language server, un agente lo scopre al momento dei test — o mai. Scrive qualcosa di plausibile, va avanti, e l’errore riemerge tre turni dopo, attaccato a una modifica diversa.

Quindi il guadagno non è davvero lo strumento lsp. È che write, edit e apply_patch sono avvolti: dopo ogni scrittura, il file passa attraverso il language server e i nuovi diagnostici vengono inseriti nel risultato dello strumento. L’agente scopre di aver rotto la build nel momento in cui l’ha rotta, che abbia pensato di chiederlo oppure no.

Ripetere ciò che è già stato detto all’agente è peggio che non dire nulla: riempie il risultato di rumore che l’agente impara a saltare a colpo d’occhio, e il nuovo errore sparisce insieme al resto. Un registro per esecuzione tiene traccia di ciò che è stato segnalato, con chiave su gravità, codice e messaggio — non sulla posizione, così un diagnostico che si è semplicemente spostato di quattro righe più in basso non viene segnalato di nuovo come nuovo.

Nessun server parte all’avvio. Ne parte uno alla prima richiesta che ne ha bisogno, che è quasi sempre la prima modifica a un file di sua competenza, e quelli inattivi vengono eliminati. Un host che non tocca mai Dart non avvia mai un analyzer.

Il rilevamento è un’intersezione: nel checkout deve esistere un marcatore di radice e il binario deve essere risolvibile. Un marcatore da solo dice “questo progetto è Python” e nulla sul fatto che il server sia installato; un binario risolvibile da solo dice che l’host ha una toolchain e nulla su questo checkout. Solo entrambi insieme significano che una query può riuscire, e uno strumento offerto che fallisce sempre è peggio di uno assente.

Perché serve la ricerca strutturale quando esiste grep

Sezione intitolata “Perché serve la ricerca strutturale quando esiste grep”

grep trova caratteri. È lo strumento giusto per un nome e quello sbagliato per una forma.

Un pattern strutturale viene analizzato con la stessa grammatica del file, quindi dispose($X) trova la chiamata comunque sia spaziata, mandata a capo o spezzata su più righe, e non trova mai quegli stessi caratteri dentro una stringa letterale o un commento.

La proprietà che lo rende più di una regex con qualche passaggio in più è che una metavariabile ripetuta in uno stesso pattern deve catturare lo stesso testo:

if ($X != null) $X.dispose()

trova una guardia che libera ciò che ha verificato, e non ne trova una che libera qualcos’altro. È una classe di bug che non puoi esprimere in alcun modo a livello testuale.

Non c’è un secondo motore. cc_natives carica già tree-sitter e le sue grammatiche per il grafo del codice, con una cache di query compilate e una tabella delle lingue. Il matcher è puro Dart su alberi che venivano analizzati comunque. Un secondo motore strutturale significherebbe un secondo parser, un secondo insieme di grammatiche da compilare e distribuire su ogni piattaforma, e due risposte a “cos’è un’espressione di chiamata”.

Una grammatica definisce solo cos’è un intero file. Analizzato da solo al livello superiore di Dart, dispose(x) è una firma di funzione del tutto valida — una dichiarazione chiamata dispose che accetta un parametro x — e non c’è alcun errore che lo tradisca. Un matcher costruito su quell’analisi cerca dichiarazioni, non trova chiamate e segnala “nessuna corrispondenza” per un pattern che non veniva mai letto come appare.

Quindi un pattern viene provato in una scala di impalcature — istruzione, espressione, membro di classe, livello superiore — e vince la prima che lo accetta. Un frammento che nessun contesto accetta viene segnalato come non analizzabile, invece di essere cercato e non trovato in silenzio.

Perché una riscrittura passa dallo staging invece di essere scritta

Sezione intitolata “Perché una riscrittura passa dallo staging invece di essere scritta”

Una riscrittura strutturale è la modifica più ampia che un agente possa fare: un pattern, quaranta file, e nessuno legge il risultato riga per riga — né il modello, né la persona che l’ha chiesta. Scriverla e dichiarare “fatto” significa che il primo sguardo onesto alla modifica è un git diff fatto dopo.

Quindi ast_edit segnala cosa farebbe — il conteggio, i file, i punti trovati — e la modifica arriva solo quando resolve la conferma. È anche ciò che permette di mettere un diff e una scelta Accetta/Annulla davanti a una persona prima che qualcosa arrivi su disco.

Il commit è tutto o niente e ogni file viene verificato rispetto al contenuto acquisito al momento dello staging. Tra lo staging e il commit, l’agente potrebbe aver modificato a mano uno di quei file, una passata di diagnostica potrebbe averlo riscritto o un formatter potrebbe averlo toccato: eseguire comunque il commit scarterebbe quel lavoro e il diff sembrerebbe voluto. Qualsiasi discrepanza rifiuta l’intera modifica, perché una riscrittura strutturale applicata solo in parte lascia un albero che non compila in nessuna delle due forme.

Cosa risponde un debugger che una print non può rispondere

Sezione intitolata “Cosa risponde un debugger che una print non può rispondere”

Il ciclo con l’istruzione print è: aggiungi una print, esegui di nuovo tutto, leggi l’output, elimina la print. Costa un’esecuzione completa dei test per ogni domanda e risponde solo alla domanda che ti eri posto prima di eseguire.

Un frame in pausa risponde in una volta sola a ogni domanda su quel momento — ogni variabile locale, ogni chiamante e un’espressione valutata nello scope del frame stesso. Per un processo bloccato o un errore che si riproduce una volta su dieci, è la differenza tra un pomeriggio e un minuto.

Delimitata come un recinto, e per lo stesso motivo

Sezione intitolata “Delimitata come un recinto, e per lo stesso motivo”

Un debug adapter possiede un processo in pausa che trattiene tutto ciò che quel processo trattiene: una porta, un lock, una connessione a un database. Una sessione che nessuno guida è una perdita che sopravvive al turno che l’ha avviata. Quindi c’è un TTL rigido, esattamente una sessione per conversazione, e un secondo launch viene rifiutato invece di sostituire silenziosamente il primo: un agente che ne avvia un secondo senza terminare il primo ha quasi certamente perso traccia del primo.

Il Debug Adapter Protocol condivide con il Language Server Protocol il framing Content-Length e nulla al di sopra: chiavi di correlazione diverse, una forma di errore diversa e un terzo tipo di messaggio (gli eventi) senza equivalente nell’LSP. Il framing vive in un unico posto; i protocolli ci poggiano sopra separatamente.

Scrivere il framing due volte significherebbe due risposte a “un messaggio si estende su due chunk”, “un chunk contiene tre messaggi” e “il server ha scritto un banner su stdout prima del suo primo frame”, e solo una di esse riceverebbe la correzione.