Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion docs/generated/release-truth.json
Original file line number Diff line number Diff line change
Expand Up @@ -135,7 +135,7 @@
"console_entrypoints": 8,
"mcp_tools": 51,
"ops_cli_commands": 5,
"pytest_test_functions": 4137
"pytest_test_functions": 4140
},
"feature_profile_matrix": {
"capture_hook": [
Expand Down
2 changes: 1 addition & 1 deletion docs/generated/release-truth.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ Do not edit this file by hand. Run `python scripts/generate_release_truth.py`.
- Main CLI commands: **119**
- Operations CLI commands: **5**
- Console entrypoints: **8**
- Pytest source test functions: **4137**
- Pytest source test functions: **4140**

## MCP tools

Expand Down
100 changes: 100 additions & 0 deletions scripts/mutate_swallowed_cause.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
"""Prueba de mutacion del guard de causa tragada (scripts/check_swallowed_cause.py).

Un test que pasa no prueba nada por si solo: prueba que el codigo actual no lo
rompe. La pregunta util es la inversa — si rompo el guard a proposito, ¿alguien
se entera? Cada mutacion de abajo desactiva UNA decision del guard. Si el test
sigue verde, esa decision no esta cubierta y el guard miente sobre su alcance.

Uso: python mutar_guard.py
"""
from __future__ import annotations

import subprocess
import sys
from pathlib import Path

MM = Path(__file__).resolve().parents[1]
GUARD = MM / "scripts" / "check_swallowed_cause.py"
TEST = "tests/test_swallowed_cause_check.py"

# (nombre, viejo, nuevo) — cada uno desactiva una decision distinta
MUTACIONES = [
(
"no exime el re-raise",
" if any(isinstance(n, ast.Raise) for n in ast.walk(handler)):\n continue\n",
"",
),
(
"ignora el escape swallow-ok",
" if ESCAPE in raw:\n continue\n",
"",
),
(
"no exime al que liga Y usa la excepcion",
" if handler.name and any(\n"
" _uses_name(stmt, handler.name) for stmt in handler.body\n"
" ):\n continue\n",
"",
),
(
"alcanza con ligar, sin usar (el bug que costo 10 dias)",
" if handler.name and any(\n"
" _uses_name(stmt, handler.name) for stmt in handler.body\n"
" ):\n continue\n",
" if handler.name:\n continue\n",
),
(
"vocabulario ancho: agrega 'status' y 'code'",
'CAUSE_KEYWORDS = frozenset({"error_code", "error", "reason", "outcome"})',
'CAUSE_KEYWORDS = frozenset({"error_code", "error", "reason", "outcome", "status", "code"})',
),
(
"marca tambien valores no-constantes",
" if kw.arg in CAUSE_KEYWORDS and isinstance(kw.value, ast.Constant):",
" if kw.arg in CAUSE_KEYWORDS:",
),
]


def main() -> int:
original = GUARD.read_text(encoding="utf-8")
sobrevivientes = []
try:
for nombre, viejo, nuevo in MUTACIONES:
if viejo not in original:
print(f" ?? {nombre}: patron no encontrado, mutacion NO aplicada")
sobrevivientes.append(nombre + " (no aplicada)")
continue
GUARD.write_text(original.replace(viejo, nuevo, 1), encoding="utf-8")
res = subprocess.run(
[sys.executable, "-m", "pytest", TEST, "-q", "--tb=no"],
cwd=MM, capture_output=True, text=True, timeout=300,
)
muerta = res.returncode != 0
fallos = [
linea for linea in res.stdout.splitlines()
if linea.startswith("FAILED")
]
print(f" {'MUERTA ' if muerta else 'SOBREVIVE'} {nombre}")
if muerta:
for f in fallos[:2]:
print(f" -> {f.split('::')[-1]}")
else:
sobrevivientes.append(nombre)
finally:
GUARD.write_text(original, encoding="utf-8")

print()
if sobrevivientes:
print(f"{len(sobrevivientes)} mutacion(es) SOBREVIVIERON — el guard tiene")
print("decisiones sin cubrir:")
for s in sobrevivientes:
print(f" - {s}")
return 1
print(f"las {len(MUTACIONES)} mutaciones murieron: cada decision del guard")
print("tiene al menos un test que la sostiene.")
return 0


if __name__ == "__main__":
sys.exit(main())
76 changes: 73 additions & 3 deletions tests/test_swallowed_cause_check.py
Original file line number Diff line number Diff line change
Expand Up @@ -90,11 +90,28 @@ def test_calla_ante_el_escape_declarado():


def test_calla_si_el_codigo_no_es_constante():
"""Un valor derivado de la excepcion ya lleva la causa adentro."""
"""Un valor NO literal no es una etiqueta fija: lleva algo adentro.

Sin ligar la excepcion A PROPOSITO. La version anterior de este test hacia
`except Exception as exc` y usaba `exc` dentro del f-string, asi que salia
exento por la regla de "liga y usa" y nunca llegaba al chequeo de constante:
pasaba por el motivo equivocado. Lo encontro una prueba de mutacion — al
sacar el `isinstance(..., ast.Constant)` este test seguia verde y la
mutacion solo moria por un AttributeError incidental en otro test.
"""
assert _n(
"try:\n f()\n"
"except Exception as exc:\n"
" repo.fail_job(1, error_code=f'failed:{type(exc).__name__}')\n"
"except Exception:\n"
" repo.fail_job(1, error_code=codigo_calculado)\n"
) == 0


def test_calla_ante_un_codigo_construido_en_f_string():
"""Mismo requisito, otra forma sintactica: JoinedStr en vez de Name."""
assert _n(
"try:\n f()\n"
"except Exception:\n"
" repo.fail_job(1, error_code=f'failed:{contexto}')\n"
) == 0


Expand All @@ -118,6 +135,59 @@ def test_calla_ante_palabras_parecidas_pero_no_de_causa():

# --- el repo real -----------------------------------------------------------

# --- los tres casos reales que originaron el guard, como fixtures -----------
# No son ejemplos inventados: son la forma exacta del codigo que costo los dias.
# Si alguien vuelve a escribir cualquiera de estas tres formas, el guard tiene
# que verla — y este test lo prueba sin depender de que el archivo original
# siga existiendo o siga escrito igual.

CASO_1_PERFIL = (
"try:\n"
" self._reduce_and_complete(run_id, now)\n"
"except AntigravityError:\n"
" self.repo.fail_run(run_id, error_code='AntigravityError')\n"
)

CASO_2_SINTESIS = (
"try:\n"
" raw = self.llm_call(SYSTEM_PROMPT, prompt)\n"
"except Exception: # noqa: BLE001 - fail closed and retry from IDs\n"
" self.repo.fail_job(job.id, owner=owner, error_code='synthesis_failed')\n"
)

CASO_3_DISCOVERY = (
"try:\n"
" self.repo.complete_job(job.id, owner=owner, outcome=outcome)\n"
"except Exception: # noqa: BLE001 - typed retry boundary persisted below\n"
" self.repo.fail_job(job.id, owner=owner, error_code='discovery_failed')\n"
)


def test_atrapa_los_tres_casos_reales_que_lo_originaron():
for nombre, fuente in (
("perfil compilado (10 dias de diagnostico equivocado)", CASO_1_PERFIL),
("sintesis PPR-7 (5 intentos, misma palabra)", CASO_2_SINTESIS),
("discovery PPR-7 (hermana que nadie habia visto)", CASO_3_DISCOVERY),
):
assert _n(fuente) == 1, f"el guard NO ve el caso real: {nombre}"


def test_los_tres_casos_reales_arreglados_ya_no_disparan():
"""La otra mitad: el arreglo que se aplico tiene que dejar de marcar.

Un guard que sigue ladrando despues del fix no distingue el problema de la
solucion, y lo unico que ensena es a silenciarlo.
"""
arreglado = (
"try:\n"
" raw = self.llm_call(SYSTEM_PROMPT, prompt)\n"
"except Exception as exc: # noqa: BLE001\n"
" self.repo.fail_job(job.id, owner=owner, error_code='synthesis_failed',\n"
" detail=f'{type(exc).__name__}: {exc}')\n"
)
assert _n(arreglado) == 0


def test_el_repo_pasa_el_guard():
"""Si esto se pone rojo, o hay un handler nuevo que tira la causa o el guard
se volvio demasiado ancho. Las dos merecen mirarse, ninguna silenciarse."""
Expand Down
Loading