Przejdź do treści
Jakość kodu

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.

Redakcjadeveloper front-end10 minkod + tabela

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.