Complessità ciclomatica: rischio nel prossimo rollout

Riassunto

La metrica di McCabe della complessità ciclomatica misura i percorsi indipendenti in una funzione. Più importante però è il delta di complessità del diff che stai shippando, incrociato con il delta dei test: un salto di complessità senza crescita corrispondente dei test è un gap di copertura mascherato da CI verde. Usare la complessità nel gating del canary, non solo nel merge, ti dice il rischio prima che diventi un incident.

Platform engineer working late at night on a rollout dashboard showing a deployment percentage graph

La complessità ciclomatica conta i percorsi indipendenti attraverso una funzione. NIST fissa il limite sicuro a 10 per funzione, e la maggior parte dei team forza qualcosa di simile nel CI. Quello che il numero non dice è quale di quei percorsi il canary del 5% in produzione eserciterà veramente, e proprio quel gap è dove nascono gli incident di rollout. Tratta la complessità ciclomatica come un input al rischio di rollout, non come una regola di linting, e il numero comincerà a guadagnarsi il suo posto.

Cosa misura davvero la complessità ciclomatica

La metrica di McCabe è un conteggio di grafi: nodi, archi, componenti connesse. Ogni if, for, while, case e operatore booleano aggiunge un percorso. Una funzione con complessità 25 ha almeno 25 percorsi linearmente indipendenti. Non è opinione, è la matematica del basis path testing, e fissa un limite minimo su quanti test case servirebbe per coprire la funzione una sola volta, da capo a fondo. La ripartizione di Sourcegraph ha tutti i dettagli: 1-10 rischio basso, 11-20 moderato, 21-50 alto, 50+ è il "dobbiamo parlarne" tier.

La maggior parte dei team non raggiunge quel limite. Gli strumenti di coverage raccontano la copertura per riga, non per percorso, quindi una funzione può mostrare il 90% di coverage mentre metà dei rami non si accendono mai in CI. È la parte che nessuno mette nel PR template, e il motivo per cui "i test passano" e "è sicuro shippare" sono due claim diversi che tutti trattano come uno.

Abbiamo letto abbastanza post-mortem per notare il pattern: la review dell'incident ha sempre una riga per consumo dell'error budget e una per time to detect. Ha quasi mai una riga su che cosa era la complessità della funzione modificata prima che il diff atterrasse. Quel vuoto nel documento, non solo negli strumenti.

Close-up of hands reviewing a code diff with nested indentation on a laptop screen

Perché un numero di complessità piatto nasconde il vero blast radius

Questa è la parte che tutti i dashboard di complessità sbagliano: due funzioni possono postare lo stesso score di complessità ciclomatica identico e portare rischi wildly diversi. Un switch statement a 20 rami che dispatcha a handler ben testati non è lo stesso animale di una funzione con quattro tasche separate di condizionali annidati sparsi in 80 linee, ognuno tre livelli di profondità. CodeScene chiama la seconda forma "bumpy road", e il nome è accurato. È la profondità di annidamento, non il numero di rami puro, che tassa il working memory e nasconde il edge case che nessuno ha pensato di testare. Lo scritto di CodeScene cammina attraverso il confronto in dettaglio, e mappa pulitamente in quello che vediamo nei dati di rollout.

Questo importa per noi specificamente perché un canary non fallisce su complessità media. Fallisce su quella una funzione bumpy che è stata toccata in questo diff, alle 2 di mattina, sotto carico che nessuno ha load-testato. Nei post-mortem che abbiamo letto con i team di platform, il pattern si ripete: la funzione root-cause quasi mai ha il score di complessità più alto del repo. Ha il delta di complessità più alto del diff che è stato shippato. Quello è un segnale diverso, e quasi nessuno lo strumenta.

Un dashboard che ti dice che la complessità del codebase è in trend down non è lo stesso dashboard che ti dice che questo rollout, adesso, ha toccato una funzione che ha appena raccattato tre nuovi rami annidati. Uno è un health report che leggi una volta al trimestre. L'altro è un gate che vorresti veramente nella deploy pipeline, seduto accanto al check SLO, non sepolto in uno strumento di static analysis separato che nessuno apre durante un incident.

I rollout ring-based e percentage-based già assumono che alcuni diff siano più rischiosi di altri; è tutta la premessa di un canary. Quello che la maggior parte delle pipeline non fa è lasciare che la forma della complessità del diff stesso influenzi la velocità con cui quel canary si allarga. È un'anomalia di code review, poi dimenticata il momento in cui il PR mergia.

Il CI gate che tutti settano a 10, e perché non muove l'ago

Il consiglio standard, cappare la complessità ciclomatica a 10 e fallire la build sopra quella, è lo step "skip" che quasi ogni blog di ingegneria raccomanda e quasi nessuno valida contro i dati reali di incident. Non è sbagliato, esattamente. È incompleto in un modo che lascia ai team credere di aver gestito il rischio quando lo hanno per lo più solo spostato.

Due failure mode, entrambi comuni nei team con cui abbiamo parlato:

Gli agenti di coding IA rendono la cosa peggio prima che la rendano migliore. Devin e simili agenti autonomi shippano PR veloce, e "veloce" spesso significa aggiungere un ramo invece di refactor quello che c'è già. È il percorso di minor resistenza per un modello che ottimizza per una suite di test passing, non per il cognitive load di un reviewer. Se il tuo team sta mergiando diff authored-by-AI a volume, il delta di complessità per PR è una metrica che vuoi nel dashboard prima che diventi un finding di incident review, non dopo.

Two engineers reviewing a branching flowchart drawn on a glass whiteboard

Cosa il delta di complessità ti dice veramente sul testing burden

Dimenticati lo score assoluto per un secondo. Il numero che predice il rischio di incident è il cambiamento di complessità introdotto da un singolo diff, incrociato rispetto a se i test che toccano quel diff sono veramente cresciuti per matchare.

Una funzione che va da complessità 8 a complessità 19 in un PR ha, per la mathematica del basis-path-testing sopra, più o meno raddoppiato il suo conteggio di test minimo richiesto. Se il PR ha aggiunto due test, hai shippato un gap di coverage mascherato da CI run passing. Quel gap non appare finché il canary non colpisce la traffic slice del 5% che esercita il ramo untestato, e a quel punto è un incident, non un commento di code review seduto unresolved in un thread PR.

Questo è il gap di strumentazione che l'AI Pilot di upstreamapi è costruito per chiudere dal lato del rollout: gatea la percentuale e hold time del canary sul delta di complessità del diff shippato, incrociato verso il suo delta di test, non solo sul SLO downstream di error rate. Un SLO di error rate ti dice che qualcosa è già rotto. Un gate di complexity-delta ti dice che il diff era più probabile rompa qualcosa prima che serva live traffic, che è il solo punto dove quella informazione è ancora actionable.

// Simplified canary gate: widen slowly on low-risk diffs, hold on high-risk ones
function canaryStep(diff: DiffMetrics): CanaryDecision {
  const complexityRisk = diff.complexityDelta / Math.max(diff.testDelta, 1);

  if (complexityRisk > 3 && diff.slo.errorBudgetBurn > 0.1) {
    return { action: "hold", trafficPct: diff.currentTrafficPct };
  }
  if (complexityRisk > 3) {
    return { action: "extend_bake_time", bakeMinutes: 45 };
  }
  return { action: "advance", trafficPct: diff.currentTrafficPct + 10 };
}

La ricerca è d'accordo su questo?

No, e vale la pena dirlo chiaramente. Qualche ricerca di practitioner spinge indietro forte sulla complessità ciclomatica come predittore di difetto alla all, argomentando che i team che ottimizzano per uno score più basso di solito spostano semplicemente la complessità da qualche parte meno visibile. La critica di GetDX fa questo caso e punta i team verso metriche developer-experience. Non pensiamo che quell'argomento uccida la metrica; pensiamo che uccida la metrica usata sola, come score statico repo-wide, disconnesso dal diff e il rollout a cui è attaccato.

Come gateresti un canary su complessità, non solo su error rate

Tre cose, nell'ordine di quanto attriti aggiungono a un PR:

  1. Calcola delta di complessità per diff, non per repo. Medie repo-wide nascondono la una funzione che importa questa settimana. Delta per PR è cheap da calcolare nella maggior parte dei strumenti di static analysis, ed è il numero che correla con quello che veramente si rompe nelle seguenti 48 ore.

  2. Incrocia verso delta di test, non conteggio di test. Una funzione con 40 test e un jump di complessità da 8 a 19 con zero test nuovo è un rischio più grande di una funzione fresca con complessità 15 e copertura matching da day one.

  3. Alimenta il ratio nel pacing del rollout, non solo in approve di merge. Un diff ad alto complexity-delta non ha bisogno di essere bloccato al review; tantissima logica di dominio legittimamente complessa (state machine, protocol parser) segnerà sempre alto. Ha bisogno di un canary più lento e un error-budget leash più corto, che è una decisione di rollout, non una decisione di code review.

Scrivi la runbook entry prima del rollout, non dopo la pagina. Se l'ingegnere on-call che apre il canale incident alle 3 di mattina deve reverse-engineer perché un PR "pulito" ha appena bruciato l'error budget, la documentazione è fallita prima che il codice lo facesse. Un commento one-line nel deploy log ("complexity delta 11, test delta 1, held at 20%") non costa nulla scrivere e salva i primi quindici minuti di ogni incident review che segue.

Server room corridor at night with status LEDs and a lone engineer walking away holding a tablet

Vale la pena trackare, o è solo un altro numero del dashboard?

Depende interamente da dove lo attacchi. La complessità ciclomatica come trend line di repo-health è per lo più decorazione: bella per una slide trimestrale, inutile alle 2 di mattina. La complessità ciclomatica come delta per-diff, alimentato nel canary pacing e incrociato verso la crescita di test, è uno dei leading indicator più cheap che abbiamo trovato per "questo rollout pagherà qualcuno".

Il post-mortem chiederà come era il budget SLO prima del rollback. Sempre più, il nostro chiede anche come era il delta di complessità sul diff che è stato shippato. Vale la pena aggiungere quella domanda al tuo runbook prima che l'incident forzi la conversazione, non durante la retro quando la risposta è una scrollata di spalle e una promessa di "aggiungere test migliori la prossima volta".

Domande frequenti

La complessità ciclomatica predice davvero gli incident?
Non da sola. L'assoluto score è un segnale debole. Il delta di complessità del diff, incrociato verso il delta di test, è quello che correla con i rollout che vanno male. Lo vedi nel pattern di incident review: la funzione root-cause quasi mai ha lo score più alto del repo, ha il delta più alto nel diff che è stato shippato.
Cosa significa se il delta di complessità è più alto del delta di test?
Significa che il diff ha espanso il numero di percorsi indipendenti attraverso una funzione ma non ha espanso il numero di test case per matchare. Per basis path testing, una funzione a complessità 25 ha bisogno di almeno 25 test case per coprire. Se il PR aggiunge 3 test e 10 percorsi, hai un gap di copertura mascherato da CI verde.
Extract Method riduce il rischio di rollout?
No, a meno che effettivamente riduca il conteggio di decisione. Spostare tre nested branch da una funzione a tre funzioni separate fa passare il lint gate ma lascia il blast radius intatto. Il numero che importa è se il test suite è cresciuto proporzionalmente.
Dovresti bloccare i PR con alta complessità al review?
Non necessariamente. Tantissimo codice legittimamente complesso (state machine, dispatcher, protocol parser) segnerà alto. Quello che vuoi è un canary più lento e un hold-time più lungo sul rollout, non un merge block al review. La decisione appartiene alla deploy pipeline, non al PR template.
Come si calcola il delta di complessità da un diff?
La maggior parte degli strumenti di static analysis (SonarQube, Codacy, CodeFactor) lo danno già per PR. Se usi uno strumento custom, calcola la complessità ciclomatica della funzione prima e dopo il diff, poi prendi la differenza. Quello è il numero che importa per la canary gate.
Qual è il rapporto ideale tra delta di complessità e delta di test?
Non c'è una risposta unica, ma il rischio aumenta quando complexityDelta / testDelta > 3. Un delta di complessità +10 con +3 test è rischioso; lo stesso delta con +10 test è gestibile. Il punto è che i test devono crescere proporzionalmente alla complessità, non come afterthought.