diff --git a/.harness/scripts/check-gap-registry.py b/.harness/scripts/check-gap-registry.py index 84dda907..7eb91a45 100755 --- a/.harness/scripts/check-gap-registry.py +++ b/.harness/scripts/check-gap-registry.py @@ -123,6 +123,49 @@ def estados_registro() -> dict[str, str]: CERRADO_REGISTRO = {"RESOLVED"} +# La cuarta superficie, y la que llevaba cinco filas mintiendo. La cabecera del board +# declara un recuento; nada lo contrastaba contra las filas, así que cerrar un ítem y +# olvidar el encabezado no lo veía nadie. Ocurrió entre el 2026-08-01 y el 2026-08-04: +# `CP-02`, `CP-04`, `CP-06`, `CP-14` y `GAP-020` pasaron a DONE y la cabecera se quedó +# en 174/17 — un documento de gobernanza afirmando 18 pendientes sobre una tabla con 13. +PROGRESO = re.compile( + r"\*\*Progress:\*\* (\d+) / (\d+) done · (\d+) pending · (\d+) in progress · " + r"(\d+) blocked · (\d+) deferred · (\d+) superseded · (\d+) wontfix" +) + + +def progreso_declarado(): + """Los números de la cabecera, o None si la línea no es legible.""" + m = PROGRESO.search(BOARD.read_text()) + if not m: + return None + d, total, p, ip, bl, df, sp, wf = (int(x) for x in m.groups()) + return ( + {"DONE": d, "TOTAL": total, "PENDING": p, "IN-PROGRESS": ip, + "BLOCKED": bl, "DEFERRED": df, "SUPERSEDED": sp, "WONTFIX": wf}, + m.group(0), + ) + + +def problemas_de_recuento(board: dict[str, str]) -> list[str]: + declarado = progreso_declarado() + if declarado is None: + # Ilegible no es correcto: una cabecera que este barrido no encuentra es + # exactamente la que puede mentir sin que nadie la contradiga. + return ["la línea `**Progress:**` del board no existe o no es legible por este guard"] + nums, linea = declarado + real = Counter(board.values()) + fallos = [] + if nums["TOTAL"] != len(board): + fallos.append(f"la cabecera dice {nums['TOTAL']} filas y la tabla tiene {len(board)}") + for estado in ("DONE", "PENDING", "IN-PROGRESS", "BLOCKED", "DEFERRED", "SUPERSEDED", "WONTFIX"): + if nums[estado] != real.get(estado, 0): + fallos.append( + f"la cabecera dice {nums[estado]} {estado} y la tabla tiene {real.get(estado, 0)}" + ) + return [f"recuento del board: {f}" for f in fallos] + + def main() -> int: board = estados_board() catalogo, dobles, sangradas = estados_catalogo() @@ -131,6 +174,8 @@ def main() -> int: problemas = [] + problemas.extend(problemas_de_recuento(board)) + for gid in sorted(set(registro) & set(fichas)): if registro[gid] != fichas[gid]: problemas.append( diff --git a/.harness/scripts/check-gap-registry.test.py b/.harness/scripts/check-gap-registry.test.py index 3e71986c..e91ebdbc 100644 --- a/.harness/scripts/check-gap-registry.test.py +++ b/.harness/scripts/check-gap-registry.test.py @@ -10,6 +10,7 @@ una contradicción y no debe reportarse como tal. """ import shutil +from collections import Counter import subprocess import sys import tempfile @@ -19,7 +20,7 @@ GUARD = Path(__file__).resolve().parent / "check-gap-registry.py" -def _repo(tmp: Path, filas, fichas=None): +def _repo(tmp: Path, filas, fichas=None, progreso=None): """Monta un repositorio de usar y tirar con las tres superficies coherentes salvo en lo que cada prueba quiera romper. `filas` = [(id, estado_board, estado_registro)]; un estado a None omite la fila en esa superficie.""" @@ -48,15 +49,30 @@ def _repo(tmp: Path, filas, fichas=None): estado = (fichas or {}).get(gid, er) registro += ["", f"### Detail {gid}", "", f"- **Status:** {estado}", ""] + # Cabecera coherente con las filas recien escritas. El guard contrasta el recuento + # declarado contra la tabla, asi que un board de prueba SIN cabecera es un board que + # miente — y relajar el guard para que la acepte seria desarmar justo lo que vigila. + # `progreso` permite a una prueba declarar numeros falsos a proposito. + conteo = Counter(eb for _gid, eb, _er in filas if eb is not None) + decl = progreso or {} + def n(estado): + return decl.get(estado, conteo.get(estado, 0)) + total = decl.get("TOTAL", sum(conteo.values())) + board.append("") + board.append( + f"**Progress:** {n('DONE')} / {total} done · {n('PENDING')} pending · " + f"{n('IN-PROGRESS')} in progress · {n('BLOCKED')} blocked · {n('DEFERRED')} deferred · " + f"{n('SUPERSEDED')} superseded · {n('WONTFIX')} wontfix" + ) (tmp / "docs/audit/tracker-gap-tracking.md").write_text("\n".join(board) + "\n") (tmp / "docs/audit/tracker-gap-reference-catalog.md").write_text("\n".join(catalogo) + "\n") (tmp / "docs/audit/tracker-gaps-opportunities-tracking.md").write_text("\n".join(registro) + "\n") return tmp / ".harness/scripts" / GUARD.name -def correr(filas, fichas=None): +def correr(filas, fichas=None, progreso=None): with tempfile.TemporaryDirectory() as d: - script = _repo(Path(d), filas, fichas) + script = _repo(Path(d), filas, fichas, progreso) p = subprocess.run([sys.executable, str(script)], capture_output=True, text=True) return p.returncode, p.stdout + p.stderr @@ -123,6 +139,41 @@ def test_fila_sin_ficha_falla(self): self.assertEqual(code, 1) self.assertIn("sin ficha", salida) + def test_cabecera_que_declara_menos_cerrados_falla(self): + """El defecto real: cinco filas cerraron y el encabezado se quedo atras. Entre el + 2026-08-01 y el 2026-08-04 `CP-02`, `CP-04`, `CP-06`, `CP-14` y `GAP-020` pasaron a + DONE con la cabecera intacta, y este guard pasaba en verde porque solo contrastaba + board, catalogo y registro — nunca el recuento.""" + code, salida = correr( + [("GAP-001", "DONE", "🟢 RESOLVED"), ("GAP-002", "DONE", "🟢 RESOLVED")], + progreso={"DONE": 1, "PENDING": 1}, + ) + self.assertEqual(code, 1) + self.assertIn("la cabecera dice 1 DONE y la tabla tiene 2", salida) + + def test_cabecera_con_total_equivocado_falla(self): + """El total es su propio contador y puede derivar solo: una fila anadida sin tocar + el encabezado no cambia ningun estado, solo el denominador.""" + code, salida = correr( + [("GAP-001", "DONE", "🟢 RESOLVED")], progreso={"TOTAL": 7} + ) + self.assertEqual(code, 1) + self.assertIn("7 filas y la tabla tiene 1", salida) + + def test_board_sin_cabecera_falla(self): + """Ilegible no es correcto. Una cabecera que este barrido no encuentra es + exactamente la que puede mentir sin que nadie la contradiga, asi que su ausencia + se reporta en vez de tolerarse.""" + with tempfile.TemporaryDirectory() as d: + script = _repo(Path(d), [("GAP-001", "DONE", "🟢 RESOLVED")]) + board = Path(d) / "docs/audit/tracker-gap-tracking.md" + board.write_text( + "\n".join(l for l in board.read_text().splitlines() if "**Progress:**" not in l) + "\n" + ) + p = subprocess.run([sys.executable, str(script)], capture_output=True, text=True) + self.assertEqual(p.returncode, 1) + self.assertIn("no es legible", p.stdout + p.stderr) + if __name__ == "__main__": unittest.main() diff --git a/docs/audit/tracker-gap-tracking.md b/docs/audit/tracker-gap-tracking.md index f145d87a..a5f00c69 100644 --- a/docs/audit/tracker-gap-tracking.md +++ b/docs/audit/tracker-gap-tracking.md @@ -222,7 +222,7 @@ This board is the single source of truth for Tracker technical debt, gaps, oppor | [`GT-480`](./tracker-gap-reference-catalog.md#gt-480) | El job de despliegue corría también para cambios de sólo documentación | ~14 min de CI para publicar dos ficheros markdown | Sale a su propio workflow con `paths-ignore`; lista negra y no blanca, porque la blanca se queda obsoleta en silencio | `Infra` | Cross | P3 | XS | `DONE` | | [`GT-481`](./tracker-gap-reference-catalog.md#gt-481) | El despliegue se comprobaba dos veces sobre el mismo árbol | ~7 min de clúster Kubernetes repetidos sobre contenido idéntico | Deja de correr en push a `main`; y las esperas fijas pasan a sondeo por hecho observable | `Infra` | Cross | P3 | XS | `DONE` | -**Progress:** 174 / 204 done · 18 pending · 0 in progress · 3 blocked · 7 deferred · 1 superseded · 1 wontfix +**Progress:** 179 / 204 done · 13 pending · 0 in progress · 3 blocked · 7 deferred · 1 superseded · 1 wontfix *(Conteos reconciliados contra `python3 .harness/scripts/check-gap-registry.py` el 2026-08-01 al cerrar `CP-01` y `CP-08`: 203 fichas / 203 filas; estados `{PENDING: 17, DONE: 174, DEFERRED: 7, BLOCKED: 3, SUPERSEDED: 1, WONTFIX: 1}`.)* **Wave 2026-06-07 → 2026-06-14 (BMAD audit + coherence):** Items `GAP-*`, `COH-*`, `OPP-*` from the PROMPT MAESTRO functional/technical/documentary audit and the source-coherence analysis (106 items: 81 resolved, 24 open, 1 blocked, 1 deferred at import time).