From 189aa7ab9a2bee474349616691623abc1a116310 Mon Sep 17 00:00:00 2001 From: Alberto Arroyo Raygada Date: Tue, 4 Aug 2026 13:28:34 -0500 Subject: [PATCH] fix(gaps): the board header counted five rows that had already closed (#142) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(gaps): the board header counted five rows that had already closed The header said 174 done / 18 pending over a table holding 179 DONE and 13 PENDING. The table was right: at 97916eb the two agreed exactly, and between 2026-08-01 and 2026-08-04 `CP-02`, `CP-04`, `CP-06`, `CP-14` and `GAP-020` moved PENDING → DONE without the header following. Each of the five was checked before trusting it rather than assumed: all five carry a current `Status: DONE` in their catalog entry, and `GAP-020`'s is dated 2026-08-02 with its reasoning, sitting under an explicit `Status (superado): PENDING` — the shape this repository already uses for a reopened item that closed again. `Gap registry coherence` passed over all of it, and could not have done otherwise: it contrasts board ↔ catalog ↔ registry and never reads the header. So the number nobody cross-checked is the one that drifted, which is the same story the guard's own docstring tells about `Status` lines. The guard now compares the declared header against the actual row counts: the total and every status bucket. An unreadable `**Progress:**` line is a failure, not a pass — a header this scan cannot find is exactly the one that can lie unchallenged. Watched failing before the header was corrected, and again on fixtures: 174/18 against a table of 179/13 → 2 problems, exit 1 pending off by one → 1 problem, exit 1 total off by one → 1 problem, exit 1 The return annotation is plain: `tuple[...] | None` needs Python 3.10 and this laptop runs 3.9, so the first version raised TypeError on import. CI has 3.12 and would never have shown it — a guard that dies being imported watches nothing, and it would have died on someone's machine, not here. Co-Authored-By: Claude Opus 5 * test(gaps): the guard had a suite and I did not run it CI caught what I should have. `check-gap-registry.py` ships `check-gap-registry.test.py`, and adding the counter contrast broke four of its nine tests: the fixture writes a synthetic board with no `**Progress:**` line, and the new check reports a missing header rather than tolerating it. The fix is the fixture, not the guard. Relaxing the check so a headerless board passes would disarm exactly what it watches — so `_repo` now emits a header derived from the rows it just wrote, and takes a `progreso` override for the tests that want to declare false numbers. Three tests added for the contrast itself, each run against the guard with the new call REMOVED and seen to fail there: · a header declaring fewer DONE than the table holds — the real defect, the five rows that closed between 2026-08-01 and 2026-08-04; · a wrong total, which drifts on its own: a row added without touching the header changes no status, only the denominator; · a board with no header at all, because illegible is not correct. Twelve pass now; nine passed before and four of those were passing over a board shape the guard would reject in reality. Co-Authored-By: Claude Opus 5 --------- Co-authored-by: Claude Opus 5 --- .harness/scripts/check-gap-registry.py | 45 ++++++++++++++++ .harness/scripts/check-gap-registry.test.py | 57 +++++++++++++++++++-- docs/audit/tracker-gap-tracking.md | 2 +- 3 files changed, 100 insertions(+), 4 deletions(-) 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).