Przegląd kodu z modelu: pięć defektów, które przechodzą testy i wychodzą u klienta
Pięć wzorców, które modele powtarzają w generowanych komponentach: nieistniejące paczki, wyścig w useEffect, innerHTML bez ucieczki i testy na atrapie.
66% deweloperów w badaniu Stack Overflow z 2025 roku wskazało jako największą frustrację przy pracy z modelami „rozwiązania prawie poprawne, ale nie do końca”. Druga w kolejności odpowiedź, 45,2% wskazań, brzmiała: debugowanie kodu z modelu zajmuje więcej czasu niż napisanie go samemu. Obie opisują ten sam koszt. Kod się kompiluje, przechodzi ścieżkę szczęśliwą, przechodzi przegląd — i wraca jako zgłoszenie od klienta trzy tygodnie po odbiorze, kiedy nikt już nie pamięta, skąd ten komponent się wziął.
Poniżej pięć klas defektów, które modele produkują powtarzalnie w komponentach frontendowych i w kodzie, który je obsługuje. Każda ma objaw, minimalny przykład i test wykrywający, który mieści się w przeglądzie kodu, a nie w osobnym audycie.
1. Import paczki, której nie ma w rejestrze
Praca Spracklen et al. przedstawiona na USENIX Security 2025 objęła 576 000 próbek kodu z 16 modeli w Pythonie i JavaScripcie. 19,7% rekomendowanych paczek nie istniało w żadnym publicznym rejestrze — łącznie 205 474 unikalne nazwy. Autorzy świadomie nie opublikowali pełnej listy, bo umożliwiłaby atak na łańcuch dostaw: wystarczy zarejestrować nazwę, którą model podpowiada powtarzalnie, i czekać, aż ktoś ją zainstaluje.
Objaw w kodzie jest niepozorny, bo import wygląda dokładnie tak, jak powinien wyglądać:
import { formatCurrency } from 'react-intl-currency-format';
import { slugify } from 'string-slugify-utils';
Obie nazwy są tu przykładowe i o to chodzi: brzmią wiarygodnie, a po nazwie nie da się orzec, czy paczka istnieje, czy powstała trzy tygodnie temu i ma jednego autora bez historii. Model zgaduje przez analogię do bibliotek, które widział, więc najczęstszy wzorzec to sklejka dwóch prawdziwych nazw.
Test wykrywający zajmuje minutę. W przeglądzie porównujesz diff pliku blokady zależności z listą nowych importów — każda nowa pozycja, która nie ma odpowiednika w opisie zadania, wymaga sprawdzenia. Dla każdej nowej paczki patrzysz na trzy rzeczy: datę pierwszej publikacji, liczbę wersji i to, czy repozytorium źródłowe w ogóle istnieje. Paczka opublikowana w tym miesiącu, w wersji 1.0.0, bez repozytorium, to nie jest zależność do wpuszczenia w projekt klienta.
Wariant droższy: paczka istnieje, jest prawdziwa i porzucona. Model uczył się na kodzie sprzed lat i podpowiada bibliotekę, której ostatnie wydanie ma cztery lata i dwie otwarte podatności. Kompiluje się bez ostrzeżenia.
Zabezpieczenie proceduralne jest tańsze niż wyłapywanie tego wzrokiem. Plik blokady zależności trafia do repozytorium i jest częścią przeglądu, a nie plikiem generowanym lokalnie i pomijanym w diffie. Instalacja w potoku budowania idzie komendą, która odtwarza wersje z pliku blokady zamiast rozwiązywać zakresy od nowa. Wtedy paczka podstawiona pod nazwę wymyśloną przez model musi przejść przez czyjś świadomy commit, a nie wjechać przy pierwszym budowaniu na czystej maszynie.
2. Pobieranie danych bez sprzątania: starsza odpowiedź nadpisuje nowszą
Najczęstszy wygenerowany komponent listy z filtrem wygląda tak:
useEffect(() => {
fetch(`/api/produkty?kategoria=${kategoria}`)
.then(r => r.json())
.then(setProdukty);
}, [kategoria]);
Kod jest poprawny składniowo i działa na maszynie, na której powstał, bo tam odpowiedzi wracają w kilkanaście milisekund i zawsze w kolejności wysłania. Dokumentacja React opisuje ten problem wprost: „odpowiedzi sieciowe mogą przyjść w innej kolejności, niż zostały wysłane”. Użytkownik klika kategorię A, potem szybko B; odpowiedź dla A wraca później i nadpisuje stan. Na ekranie zostaje filtr B i dane A.
Wzorzec z dokumentacji React polega na fladze ustawianej w funkcji czyszczącej:
useEffect(() => {
let ignore = false;
setProdukty(null);
fetch(`/api/produkty?kategoria=${kategoria}`)
.then(r => r.json())
.then(dane => {
if (!ignore) setProdukty(dane);
});
return () => { ignore = true; };
}, [kategoria]);
Wersja z AbortController dodatkowo przerywa samo żądanie, zamiast tylko ignorować wynik. Ma sens tam, gdzie odpowiedzi są duże albo płatne za wywołanie:
useEffect(() => {
const kontroler = new AbortController();
fetch(`/api/produkty?kategoria=${kategoria}`, { signal: kontroler.signal })
.then(r => r.json())
.then(setProdukty)
.catch(e => {
if (e.name !== 'AbortError') setBlad(e);
});
return () => kontroler.abort();
}, [kategoria]);
Warunek w obsłudze błędu nie jest ozdobą. Przerwane żądanie odrzuca obietnicę wyjątkiem AbortError, więc bez tego sprawdzenia każda zmiana filtra zapala użytkownikowi komunikat o błędzie sieci. Modele generują ten blok z AbortController, ale bez rozróżnienia w catch, wystarczająco często, żeby sprawdzać to odruchowo. Sama flaga ignore wystarcza do usunięcia błędnego stanu na ekranie i nie ma tego problemu.
Dlaczego to przechodzi przegląd: w trybie Strict React wykonuje w środowisku deweloperskim dodatkowy cykl uruchomienia i czyszczenia efektu, zanim wykona ten właściwy. To celowy test obciążeniowy sprawdzający, czy czyszczenie odwraca to, co robi uruchomienie. Deweloper widzi wtedy dwa żądania w panelu sieci, uznaje to za znany artefakt trybu deweloperskiego i przestaje się przyglądać — a brak funkcji czyszczącej to osobny błąd, który tryb Strict właśnie próbował pokazać.
Test wykrywający: w panelu sieci ustaw dławienie na wolne łącze i przeklikaj filtry szybciej, niż wracają odpowiedzi. Jeśli po ustabilizowaniu widoku dane nie zgadzają się z aktywnym filtrem, defekt jest potwierdzony. Zajmuje to dziesięć sekund i nie wymaga czytania kodu.
3. Dane z zewnątrz trafiają do DOM bez ucieczki
Raport Veracode „2025 GenAI Code Security Report” objął ponad 100 modeli. 45% wygenerowanych próbek nie przeszło testów bezpieczeństwa i wprowadzało podatności z listy OWASP Top 10. Dla JavaScriptu odsetek próbek bez podatności wyniósł 57%. Najgorzej wypadła kategoria zależna od kontekstu: przy cross-site scriptingu (CWE-80) modele nie zabezpieczyły kodu w 86% odpowiednich próbek.
Powód jest strukturalny. Model nie wie, skąd pochodzi zmienna, którą wstawia do dokumentu. Jeśli w promptcie było „wyświetl opis produktu”, wygeneruje najkrótszą rzecz, która to robi:
element.innerHTML = produkt.opis;
W przeglądzie szukasz trzech konstrukcji, niezależnie od stosu: innerHTML, dangerouslySetInnerHTML i v-html. Każde wystąpienie wymaga odpowiedzi na jedno pytanie: kto zapisuje tę wartość. Jeśli redaktor w panelu treści albo użytkownik w formularzu — potrzebne jest oczyszczanie po stronie serwera lub biblioteka sanityzująca, nie ucieczka na wyjściu doklejona ręcznie. Jeśli wartość jest tekstem, właściwą konstrukcją jest textContent.
Test wykrywający: wpisz w pole, które trafia do tego widoku, ładunek <img src=x onerror=alert(1)> i odśwież stronę. Alert oznacza podatność w kodzie, który przeszedł przegląd.
4. Walidacja mieszka wyłącznie w komponencie formularza
Model dostaje zadanie „formularz kontaktowy z walidacją” i wykonuje je dosłownie: atrybuty required, wzorzec dla adresu e-mail, komunikaty o błędach, blokada przycisku. Wszystko w komponencie. Punkt końcowy po stronie serwera przyjmuje w tym czasie dowolny ładunek, bo o nim w zadaniu nie było mowy.
To nie jest błąd modelu — to błąd granicy zadania. Model odpowiada na prompt, a prompt dotyczył formularza. Konsekwencją bywa baza pełna rekordów z pustym adresem i skrzynka zapchana spamem z automatu, który nigdy nie otworzył strony.
Reguła pod spodem jest starsza niż generowanie kodu i wraca wszędzie tam, gdzie coś wywołuje coś innego przez sieć: warunek egzekwuje strona, która ponosi konsekwencje, a nie strona, która wysyła żądanie. Ten sam mechanizm zawodzi przy nadawaniu uprawnień automatom w systemach firmy — opisuje to tekst o tym, jak agent podłączony do CRM widzi więcej danych, niż potrzebuje do zadania. Formularz i agent różnią się skalą, nie klasą błędu.
Test wykrywający omija interfejs:
curl -X POST https://przyklad.pl/api/kontakt \
-H 'Content-Type: application/json' \
-d '{"email":"","wiadomosc":"","zgoda":false}'
Odpowiedź 200 przy pustym ładunku i braku zgody kończy przegląd tego punktu końcowego. Ta sama uwaga dotyczy limitu długości pola: walidacja po stronie klienta ucina tekst na 500 znakach, serwer przyjmuje dwa megabajty.
5. Testy, które sprawdzają atrapę zamiast zachowania
Testy wygenerowane razem z komponentem wyglądają na pełne pokrycie, bo mają nazwy przypadków, asercje i zielony wynik. Część z nich weryfikuje wyłącznie to, że atrapa zwróciła to, co jej kazano zwrócić:
vi.mock('./api', () => ({
pobierzProdukty: () => Promise.resolve([{ id: 1, nazwa: 'Test' }])
}));
it('pokazuje produkty', async () => {
render(<Lista />);
expect(await screen.findByText('Test')).toBeInTheDocument();
});
Ten test przejdzie również wtedy, gdy komponent zignoruje filtr, pominie obsługę błędu i wyrenderuje pustą listę przy odpowiedzi 500. Sprawdza jedną ścieżkę, tę samą, którą model miał w głowie, pisząc implementację.
Przypadek, który realnie coś zabezpiecza, sprawdza zachowanie przy odpowiedzi, której model nie miał w głowie:
it('pokazuje komunikat, gdy API zwraca 500', async () => {
pobierzProdukty.mockRejectedValueOnce(new Error('500'));
render(<Lista />);
expect(await screen.findByRole('alert')).toBeInTheDocument();
});
Ten test przechodzi tylko wtedy, gdy komponent ma obsługę błędu i renderuje ją w sposób dostępny dla czytnika ekranu. Dwie rzeczy naraz, jedną asercją.
Test wykrywający jest brutalny i szybki: zepsuj implementację celowo — odwróć warunek, usuń zależność z tablicy, zwróć pustą tablicę — i uruchom zestaw testów. Jeśli nadal jest zielony, ten plik testów nie jest zabezpieczeniem, tylko kosztem utrzymania. Wtedy albo dopisujesz przypadek na błąd i stan pusty, albo usuwasz plik i mówisz wprost, że tego obszaru nie pokrywasz.
Kolejność przeglądu, gdy masz na niego dwadzieścia minut
Przegląd kodu z modelu opłaca się prowadzić w kolejności od defektów najtańszych w wykryciu do tych, które wymagają czytania logiki. Cztery pierwsze pozycje z tabeli nie wymagają rozumienia, co komponent ma robić.
| Defekt | Czego szukasz | Test wykrywający | Czas |
|---|---|---|---|
| Nieistniejąca lub porzucona zależność | Nowe pozycje w pliku blokady | Instalacja w czystym kontenerze, data wydania paczki | 2 min |
| Wstrzyknięcie do DOM | innerHTML, dangerouslySetInnerHTML, v-html |
Ładunek <img src=x onerror=> w polu wejściowym |
3 min |
| Walidacja tylko po stronie klienta | Punkt końcowy przyjmujący dane z formularza | Żądanie curl z pustym ładunkiem |
3 min |
| Wyścig przy pobieraniu danych | useEffect bez funkcji czyszczącej |
Dławienie łącza plus szybka zmiana filtrów | 2 min |
| Test asertujący na atrapie | Atrapa modułu z zaszytą odpowiedzią | Celowe zepsucie implementacji, ponowny przebieg testów | 5 min |
Ta kolejność ma jeszcze jedną zaletę: cztery z pięciu testów da się uruchomić, nie znając projektu. To robi z nich sensowny etap odbioru pracy podwykonawcy, a nie tylko element wewnętrznego przeglądu.
Co z tym zrobić przed najbliższym odbiorem
Jeśli w projekcie, który oddajesz w tym tygodniu, żaden z tych pięciu testów nie został uruchomiony, zacznij od dwóch: ładunku XSS w polu, które trafia do widoku publicznego, oraz żądania curl na punkt końcowy formularza. To razem sześć minut i dwie klasy defektów, które kosztują najwięcej po wdrożeniu — jedna reputacyjnie, druga w postaci ręcznego czyszczenia bazy.
Reszta jest decyzją procesową, nie techniczną: albo te pozycje wchodzą na stałą listę odbioru i ktoś ma je odhaczyć przed wystawieniem faktury, albo pozostają dobrą intencją, którą pomija się przy każdym terminie. Model nie przestanie produkować tych pięciu wzorców, bo one wynikają z tego, jak działa — z odpowiadania na treść zadania, a nie na kontekst systemu, w którym kod ma żyć. Kontekst dokłada człowiek, w przeglądzie, przed odbiorem.