Parte: 20 — IA generativa y desarrollo de juegos asistido por IA · Fuente: Prácticas de revisión de código y testing · OWASP Top 10 for LLM Applications ⏱️ Duración estimada: 110 min · Nivel: Avanzado
Establecer el ciclo de verificación obligatorio para todo código generado: generar → compilar → probar → inspeccionar → perfilar → validar. Y establecer la regla que lo justifica: el código generado es una propuesta, no una solución. Compila, parece correcto, sigue el estilo — y puede estar mal de formas que el código escrito a mano rara vez está.
Esta clase no es sobre desconfianza genérica. Es sobre los modos de fallo específicos del código generado, que son distintos de los humanos: usa APIs que no existen, resuelve el caso general ignorando el particular que importaba, introduce dependencias silenciosas, y —el más peligroso— es plausible en la superficie y erróneo en un caso límite que nadie mira. Vas a aprender a detectar cada uno con una comprobación concreta, y a integrar todo eso en la CI para que no dependa de la disciplina de nadie.
Al finalizar, el alumno podrá:
| # | Tema | Por qué importa |
|---|---|---|
| 1 | El ciclo obligatorio | Convierte una norma en un procedimiento. |
| 2 | Compilar no es funcionar | El primer filtro, y el más engañoso. |
| 3 | Tests antes de generar | Si el criterio lo pone el generador, no hay criterio. |
| 4 | APIs inexistentes | El fallo más frecuente y el más fácil de detectar. |
| 5 | Casos límite | Donde el código generado falla de forma característica. |
| 6 | Manejo de errores | Casi siempre optimista de más. |
| 7 | Dependencias silenciosas | Se cuelan sin que nadie las apruebe. |
| 8 | Determinismo y rendimiento | Dos regresiones invisibles a la vista. |
| 9 | Seguridad | Validación de entrada y datos del jugador. |
| 10 | En la CI | Lo que no está automatizado, no ocurre. |
Godot 4.x, --headless para compilar y ejecutar tests, GUT o scripts SceneTree (Parte 15 y clase 320), los presupuestos de rendimiento de la clase 321 y los replays deterministas de la clase 308. Trabajaremos en scripts/verificar/ y en la CI. Todo lo de esta clase es independiente del generador: se aplica igual venga el código de donde venga.
1. GENERAR con la especificación de la clase 326
│
2. COMPILAR ──► APIs inexistentes, errores de sintaxis y de tipo
│ godot --headless --check-only / --import
3. PROBAR ──► lógica incorrecta en los casos que TÚ escribiste
│ los tests, escritos ANTES
4. INSPECCIONAR ──► casos límite, manejo de errores, dependencias, estilo
│ revisión humana dirigida
5. PERFILAR ──► regresiones de rendimiento y de determinismo
│ presupuestos (clase 321) + replays (clase 308)
6. VALIDAR ──► ¿resuelve el problema real? ← lo único que no se automatiza
El paso 6 es el que no se puede delegar: los cinco anteriores comprueban que el código hace lo que dice; el sexto, que era eso lo que hacía falta.
# pruebas/ordenar_inventario_test.gd
# ESCRITO ANTES de generar `Inventario.ordenar()`. Si los tests se escriben
# después, y peor aún si los escribe el mismo generador, acaban describiendo lo
# que el código hace en vez de lo que debería hacer.
extends SceneTree
func _init() -> void:
var hechas := 0; var fallos := 0
var check := func(ok: bool, que: String):
hechas += 1
if not ok: fallos += 1; printerr(" FALLA ", que)
# 1. Conservación: la propiedad que NUNCA puede romperse.
var inv := _inv_con({&"pocion_menor": 47, &"mineral_hierro": 13, &"espada_hierro": 2})
var antes := _cuenta_por_item(inv)
inv.ordenar()
check.call(_cuenta_por_item(inv) == antes, "ordenar no cambia el total por item")
# 2. Compactación.
check.call(_stacks_parciales(inv, &"pocion_menor") <= 1, "no quedan dos stacks parciales")
# 3. Huecos al final.
check.call(_huecos_al_final(inv), "las ranuras vacías quedan al final")
# 4. Idempotencia.
var d1 := inv.a_dict(); inv.ordenar()
check.call(inv.a_dict() == d1, "ordenar dos veces no cambia nada")
# 5. Casos límite: vacío y lleno. Los que el código generado suele fallar.
var vacio := Inventario.new(_base, 10)
vacio.ordenar()
check.call(vacio.libres() == 10, "ordenar un inventario vacío no rompe nada")
var lleno := _inv_lleno_sin_huecos()
var antes_lleno := lleno.a_dict()
lleno.ordenar()
check.call(_cuenta_por_item(lleno) == _cuenta_de(antes_lleno),
"ordenar un inventario lleno conserva todo")
# 6. Señales: solo de lo que cambió.
var emitidas := []
var inv2 := _inv_ya_ordenado()
inv2.ranura_cambiada.connect(func(i): emitidas.append(i))
inv2.ordenar()
check.call(emitidas.is_empty(), "ordenar algo ya ordenado no emite señales")
print("== %d comprobaciones, %d fallos ==" % [hechas, fallos])
quit(1 if fallos > 0 else 0)
#!/usr/bin/env python3
"""Detecta llamadas a métodos que no existen en las clases del proyecto.
El compilador de GDScript pilla muchos casos, pero no todos: las llamadas
dinámicas sobre `Variant` pasan de largo y explotan en runtime.
"""
import os, re, sys, subprocess
RAIZ = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
LLAMADA = re.compile(r"\b([A-Z]\w+)\.(\w+)\s*\(")
def metodos_declarados():
"""clase -> {métodos} a partir de los `class_name` del proyecto."""
mapa = {}
for cur, _, ficheros in os.walk(os.path.join(RAIZ, "res")):
for f in ficheros:
if not f.endswith(".gd"):
continue
texto = open(os.path.join(cur, f), encoding="utf-8").read()
m = re.search(r"^class_name\s+(\w+)", texto, re.M)
if not m:
continue
mapa[m.group(1)] = set(re.findall(r"^\s*(?:static\s+)?func\s+(\w+)", texto, re.M))
return mapa
def main():
declarados = metodos_declarados()
fallos = []
for cur, _, ficheros in os.walk(os.path.join(RAIZ, "res")):
for f in ficheros:
if not f.endswith(".gd"):
continue
ruta = os.path.join(cur, f)
for n, linea in enumerate(open(ruta, encoding="utf-8"), 1):
for clase, metodo in LLAMADA.findall(linea):
# Solo comprobamos NUESTRAS clases: las del motor las valida Godot.
if clase in declarados and metodo not in declarados[clase]:
fallos.append(f"{os.path.relpath(ruta, RAIZ)}:{n}: "
f"{clase}.{metodo}() no existe")
for x in fallos:
print(f"::error::{x}")
print(f"== APIs comprobadas: {len(fallos)} inexistente(s) ==")
return 1 if fallos else 0
if __name__ == "__main__":
sys.exit(main())
| Modo de fallo | Ejemplo típico | Cómo se detecta |
|---|---|---|
| API inexistente | Array.remove(i) (en Godot 4 es remove_at) |
Compilación + script del paso 3 |
| Caso límite ignorado | Divide sin comprobar cero; itera sin comprobar vacío | Tests de límites escritos antes |
| Manejo optimista | FileAccess.open(...).get_as_text() sin comprobar null |
Búsqueda de patrón + revisión |
| Off-by-one | for i in range(n - 1) donde debía ser n |
Test con el caso de tamaño 1 |
| Aleatoriedad no controlada | randf() global en lugar del RNG inyectado |
Búsqueda + test de replay |
| Dependencia temporal | Time.get_ticks_msec() en dominio/ |
Búsqueda + test de determinismo |
| Dependencia nueva | Un preload de algo que no estaba |
Diff de importaciones |
| Complejidad innecesaria | Una jerarquía de clases para tres casos | Revisión humana |
| Rendimiento | Bucle anidado donde había un diccionario | Presupuestos (clase 321) |
| Validación ausente | Confía en el dato del jugador | Revisión de seguridad |
grep -rnE "randf\(\)|randi\(\)|randomize\(\)" res/dominio/
grep -rnE "Time\.get_ticks|Time\.get_unix|OS\.get_time" res/dominio/
grep -rnE "(FileAccess\.open|get_node|instantiate)\([^)]*\)\.\w+" res/
git diff --name-only | xargs grep -l "^preload\|^const .* = preload" || true
Cada una de esas cuatro búsquedas corresponde a una fila de la tabla anterior. Automatizadas, tardan un segundo; a ojo, se olvidan.
# pruebas/determinismo_test.gd — la misma semilla debe dar el mismo resultado.
extends SceneTree
func _init() -> void:
var a := _simular(semilla=777, ticks=600)
var b := _simular(semilla=777, ticks=600)
var c := _simular(semilla=778, ticks=600)
var fallos := 0
if a != b:
fallos += 1
printerr(" el sistema NO es determinista con la misma semilla")
printerr(" (busca randf() global, Time.* o iteración de Dictionary sin ordenar)")
if a == c:
fallos += 1
printerr(" dos semillas distintas dan el mismo resultado: ¿se usa la semilla?")
print("== 2 comprobaciones, %d fallos ==" % fallos)
quit(1 if fallos > 0 else 0)
Y el rendimiento, con los presupuestos que ya tienes de la clase 321: un cambio generado que sustituya una búsqueda en diccionario por un bucle sobre un array funcionará perfectamente y multiplicará el coste por mil. Los tests no lo ven; el presupuesto, sí.
## Lista de revisión de código generado
### Corrección
- [ ] ¿Qué pasa con entrada vacía, cero, negativa, nula y el máximo?
- [ ] ¿Los bucles cubren el primer y el último elemento?
- [ ] ¿Se comprueban los retornos que pueden ser `null`?
- [ ] ¿Los errores se manejan o se ignoran?
### Encaje
- [ ] ¿Usa las clases y utilidades que ya existen en el proyecto?
- [ ] ¿Respeta la regla de dependencia entre capas?
- [ ] ¿Sigue las convenciones de `CONVENCIONES.md`?
- [ ] ¿Ha aparecido alguna dependencia nueva?
### Riesgos silenciosos
- [ ] ¿Introduce aleatoriedad o dependencia del reloj no controlada?
- [ ] ¿Puede ser más lento que lo que sustituye?
- [ ] ¿Valida los datos que vienen del jugador o de la red?
- [ ] ¿Se ha modificado algún test para que pase?
### Lo esencial
- [ ] ¿Resuelve el problema que teníamos, o uno parecido?
- [ ] ¿Podría mantenerlo alguien del equipo dentro de seis meses?
verificacion:
name: Verificación de código
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
- name: Instalar Godot
run: bash scripts/instalar_godot.sh
- name: Compilar (detecta APIs inexistentes y errores de tipo)
run: |
set -euo pipefail
godot --headless --path . --import 2>&1 | tee import.log
if grep -E "SCRIPT ERROR|Parse Error|ERROR:" import.log; then
echo "::error::el proyecto no compila limpio"; exit 1
fi
- name: APIs del proyecto
run: python scripts/verificar/apis.py
- name: Patrones prohibidos en dominio/
run: |
set -euo pipefail
# El dominio no puede usar aleatoriedad global ni el reloj del
# sistema: rompería replays, tests y determinismo de red.
if grep -rnE "randf\(\)|randi\(\)|Time\.get_" res/dominio/; then
echo "::error::patrón prohibido en dominio/ (usa RNG inyectado y tick(delta))"
exit 1
fi
- name: Tests
run: godot --headless --script res://pruebas/todos.gd
- name: Determinismo
run: godot --headless --script res://pruebas/determinismo_test.gd
- name: Replays de regresión
run: godot --headless --script res://pruebas/replays_test.gd
- name: Presupuestos de rendimiento
run: godot --headless --script res://pruebas/rendimiento/medir.gd
dominio/.randf() global y comprueba que el test de determinismo lo detecta.Implementa el ciclo de verificación completo y automatizado: compilación limpia, detector de APIs inexistentes del proyecto, comprobación de patrones prohibidos por capa, batería de tests con casos límite escritos antes, test de determinismo, replays de regresión y presupuestos de rendimiento; todo integrado en un job de CI.
Criterio de aceptación: (a) el job falla si se introduce una llamada a un método inexistente de una clase del proyecto, indicando archivo, línea y método; (b) falla si aparece randf(), randi() o Time.get_* dentro de dominio/; (c) falla si el sistema deja de ser determinista con la misma semilla; (d) falla si un replay de regresión diverge, indicando el tick; (e) falla si un presupuesto de rendimiento se supera; (f) los tests de casos límite cubren, para al menos una función, entrada vacía, cero, negativa, máxima y máxima más uno; (g) la lista de revisión humana está documentada en el repositorio e incluye la comprobación de tests modificados.
| Síntoma / mensaje | Causa y cómo arreglar |
|---|---|
| "Invalid call. Nonexistent function" en runtime | API alucinada que el compilador no vio. Detector estático + tipado estricto. |
| El código funciona con datos normales y falla con vacío | Caso límite ignorado. Escribe los tests de límites antes. |
| Los replays empiezan a divergir sin motivo | Se coló aleatoriedad o el reloj. Comprobación de patrones prohibidos. |
| El juego va más lento tras un cambio "equivalente" | Regresión de rendimiento. Presupuestos en CI. |
| Aparece una dependencia nueva en el proyecto | No se revisó el diff de importaciones. Añádelo a la revisión. |
| Los tests pasan y el bug sigue | Los tests se escribieron después y describen el código. Escríbelos antes. |
| Se acepta el código porque compila | Compilar es el primer filtro, no el último. Ciclo completo. |
| El código no encaja con lo que ya existe | Falta contexto en la generación y revisión de encaje. Ambas cosas. |
❓ ¿No es todo esto lo que ya se hace con cualquier código? En buena medida sí, y esa es la conclusión tranquilizadora: no hace falta un proceso nuevo, hace falta aplicar el que ya deberías tener. Lo que cambia es el énfasis: el código generado falla más en casos límite y en manejo de errores, y menos en sintaxis. La revisión se enfoca ahí.
❓ ¿Escribir los tests antes no es más lento? Al principio sí, unos minutos. Después es más rápido, porque el criterio queda fijado y no hay discusión sobre si el resultado es correcto. Y en el contexto de esta parte tiene una razón añadida: si el criterio lo pone el generador, no hay criterio.
❓ ¿Cuánto código generado revisáis línea a línea? Regla práctica: todo lo que toque seguridad, economía, guardado o red, línea a línea; el resto, por muestreo (tres archivos al azar) más las comprobaciones automáticas. Si la muestra sale mal, se revisa entero.
❓ ¿Y si el código generado es mejor que el mío? Puede serlo, y no cambia nada del proceso: sigue teniendo que compilar, pasar tests, no introducir dependencias y ser mantenible por el equipo. El criterio no es quién lo escribió, es si cumple.
❓ ¿Merece la pena si genero poco código? Sí, porque todas estas comprobaciones son útiles al margen del origen del código: el detector de APIs, los patrones prohibidos por capa, el test de determinismo y los presupuestos detectan errores humanos exactamente igual. La IA solo ha hecho más evidente que hacían falta.
--import, --script): https://docs.godotengine.org/en/4.3/tutorials/editor/command_line_tutorial.html · uso: lectura de respaldo del objetivo; no se usa en el procedimientoClase 327 - Agentes de programación para GameDev