From 0f042774d1bd5e6dd585f6956a623970b030cc86 Mon Sep 17 00:00:00 2001 From: wolverinaton Date: Sun, 30 Aug 2026 21:32:34 -0300 Subject: [PATCH] test(guard): mutacion sobre el guard de causa, y un test que pasaba por el motivo equivocado La prueba de mutacion encontro exactamente lo que se busca con ella: un test verde que no probaba lo que decia su nombre. `test_calla_si_el_codigo_no_es_constante` escribia `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. Al mutar el guard sacandole el `isinstance(..., ast.Constant)`, el test seguia verde: la mutacion solo moria por un AttributeError incidental en otro test. Ahora el caso no liga la excepcion, mas uno nuevo para la otra forma sintactica (JoinedStr vs Name), y esa mutacion muere por dos tests dedicados. Agregado tambien lo que faltaba del contrato de cierre: - Los TRES casos reales que originaron el guard quedan como fixtures literales (perfil compilado, sintesis PPR-7, discovery PPR-7). No dependen de que el archivo original siga escrito igual, y prueban las dos mitades: el guard ve las tres formas rotas, y NO ve la forma arreglada. Un guard que sigue ladrando despues del fix no distingue el problema de la solucion. - `scripts/mutate_swallowed_cause.py`: seis mutaciones, cada una desactiva UNA decision del guard (exencion del re-raise, escape declarado, "liga y usa", "alcanza con ligar", vocabulario ancho, constante vs derivado). Las seis mueren. Queda como artefacto repetible en vez de una corrida perdida. 14/14, ruff limpio, mutacion sale 0. --- docs/generated/release-truth.json | 2 +- docs/generated/release-truth.md | 2 +- scripts/mutate_swallowed_cause.py | 100 ++++++++++++++++++++++++++++ tests/test_swallowed_cause_check.py | 76 ++++++++++++++++++++- 4 files changed, 175 insertions(+), 5 deletions(-) create mode 100644 scripts/mutate_swallowed_cause.py diff --git a/docs/generated/release-truth.json b/docs/generated/release-truth.json index 4c892b7..84258f6 100644 --- a/docs/generated/release-truth.json +++ b/docs/generated/release-truth.json @@ -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": [ diff --git a/docs/generated/release-truth.md b/docs/generated/release-truth.md index a1a2e1d..822dcb7 100644 --- a/docs/generated/release-truth.md +++ b/docs/generated/release-truth.md @@ -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 diff --git a/scripts/mutate_swallowed_cause.py b/scripts/mutate_swallowed_cause.py new file mode 100644 index 0000000..6ad75b1 --- /dev/null +++ b/scripts/mutate_swallowed_cause.py @@ -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()) diff --git a/tests/test_swallowed_cause_check.py b/tests/test_swallowed_cause_check.py index eee60f1..8015b51 100644 --- a/tests/test_swallowed_cause_check.py +++ b/tests/test_swallowed_cause_check.py @@ -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 @@ -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."""