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
8 changes: 8 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -236,6 +236,14 @@ jobs:
- name: Board y catalogo deben contar lo mismo
run: python3 .harness/scripts/check-gap-registry.py

# El paso anterior contrasta ademas el REGISTRO, la tercera superficie, anadida el
# 2026-08-01: board y catalogo llevaban sincronizados desde que existe el guard y
# nadie miraba el registro, que comparte el mismo espacio de ids. De los 34 ids
# comunes, CINCO se contradecian. Estas pruebas son negativas: comprueban que el
# contraste se pone rojo cuando debe, no que exista.
- name: Self-tests for the board/registro contrast
run: python3 .harness/scripts/check-gap-registry.test.py

# Las fichas del board recogen hallazgos de auditoria y nada las vuelve a
# contrastar con el codigo, asi que envejecen en silencio mientras siguen
# dirigiendo prioridades. El 2026-08-01 se encontraron CINCO reclamando trabajo ya
Expand Down
55 changes: 55 additions & 0 deletions .harness/scripts/check-gap-registry.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,8 @@
RAIZ = Path(__file__).resolve().parent.parent.parent
BOARD = RAIZ / "docs/audit/tracker-gap-tracking.md"
CATALOGO = RAIZ / "docs/audit/tracker-gap-reference-catalog.md"
# Tercera superficie, añadida el 2026-08-01. Ver `estados_registro`.
REGISTRO = RAIZ / "docs/audit/tracker-gaps-opportunities-tracking.md"

# Tolerante a sangrado y a viñeta con `*`: el objetivo es DETECTAR el desorden, no
# tropezar con él y reportar un falso "no existe".
Expand Down Expand Up @@ -57,12 +59,60 @@ def estados_catalogo() -> tuple[dict[str, str], list[str], list[str]]:
return estados, dobles, sangradas


def estados_registro() -> dict[str, str]:
"""Estados del REGISTRO, la tercera superficie.

Se añadió el 2026-08-01 después de medir el agujero: board y catálogo llevaban
sincronizados desde que existe esta comprobación, y el registro —que comparte el mismo
espacio de identificadores— no lo miraba nadie. De los 34 ids que las dos superficies
tienen en común, **5 se contradecían**: `COH-012`, `GAP-016`, `GAP-023` y `GAP-025`
figuraban cerrados en el board y abiertos en el registro, y `GAP-020` al revés. En dos
de esos casos el board mentía (los ficheros «traducidos» seguían en español) y en tres
mentía el registro. No es que una superficie sea de fiar y la otra no: es que nada las
obligaba a coincidir.
"""
estados = {}
for linea in REGISTRO.read_text().splitlines():
if not linea.startswith("|"):
continue
celdas = linea.split("|")
if len(celdas) < 4:
continue
m = re.search(r"\[([A-Z]+-[A-Z0-9/]+)\]", celdas[3])
if not m:
continue
estado = next(
(s for s in ("BLOCKED", "OPEN", "DEFERRED", "RESOLVED") if s in celdas[2]), None
)
if estado:
estados[m.group(1)] = estado
return estados


# Los dos vocabularios no coinciden y NO se traducen entre sí: inventar un mapa
# `OPEN→PENDING`, `DEFERRED→?` sería una decisión disfrazada de comprobación. Se compara
# sólo lo que ambas superficies afirman sin ambigüedad — si el ítem está cerrado o no —
# que es justo la dimensión en la que se contradecían.
CERRADO_BOARD = {"DONE"}
CERRADO_REGISTRO = {"RESOLVED"}


def main() -> int:
board = estados_board()
catalogo, dobles, sangradas = estados_catalogo()
registro = estados_registro()

problemas = []

for gid in sorted(set(board) & set(registro)):
cerrado_b = board[gid] in CERRADO_BOARD
cerrado_r = registro[gid] in CERRADO_REGISTRO
if cerrado_b != cerrado_r:
problemas.append(
f"{gid}: el board dice {board[gid]} y el registro dice {registro[gid]} — "
f"uno lo da por cerrado y el otro no"
)

desync = [(g, board[g], catalogo[g]) for g in catalogo if g in board and board[g] != catalogo[g]]
for gid, b, c in sorted(desync):
problemas.append(f"{gid}: el board dice {b} y su ficha dice {c}")
Expand All @@ -82,8 +132,13 @@ def main() -> int:
print(f" · {p}")
return 1

comunes = len(set(board) & set(registro))
print(f"Registro coherente: {len(catalogo)} fichas / {len(board)} filas.")
print(f" {dict(Counter(board.values()))}")
# El denominador se imprime siempre. Board y registro comparten espacio de ids pero
# sólo se solapan en una parte: fuera de esos ids nada aquí comprueba nada, y verlo
# escrito evita leer el verde como si cubriera las tres superficies enteras.
print(f" board∩registro: {comunes} id(s) contrastados de {len(registro)} en el registro.")
return 0


Expand Down
94 changes: 94 additions & 0 deletions .harness/scripts/check-gap-registry.test.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
#!/usr/bin/env python3
"""Pruebas del contraste board ↔ registro añadido a `check-gap-registry.py`.

Todas menos una son NEGATIVAS. Un guard que sólo se ha visto pasar es indistinguible de
uno roto, y esa confusión ya costó cara en este repositorio: el board daba por traducidos
dos ficheros que seguían en español, y todo estaba verde.

La última prueba es la que evita el falso positivo simétrico: board y registro sólo se
solapan en una parte de sus ids, así que un id presente en uno y ausente en el otro NO es
una contradicción y no debe reportarse como tal.
"""
import shutil
import subprocess
import sys
import tempfile
import unittest
from pathlib import Path

GUARD = Path(__file__).resolve().parent / "check-gap-registry.py"


def _repo(tmp: Path, filas):
"""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."""
(tmp / ".harness/scripts").mkdir(parents=True)
(tmp / "docs/audit").mkdir(parents=True)
shutil.copy(GUARD, tmp / ".harness/scripts" / GUARD.name)

board = ["| Gap | Qué | Estado |", "|---|---|---|"]
catalogo = ["# Catálogo", ""]
registro = [
"| # | Status | ID | Type |",
"|---|---|---|---|",
]
for n, (gid, eb, er) in enumerate(filas, start=1):
if eb is not None:
board.append(f"| [`{gid}`](./cat.md#{gid.lower()}) | algo | `{eb}` |")
catalogo += [f"#### {gid}", "", f"- **Status:** `{eb}`", ""]
if er is not None:
registro.append(f"| {n} | {er} | [{gid}](#detail-{gid.lower()}) | GAP |")

(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):
with tempfile.TemporaryDirectory() as d:
script = _repo(Path(d), filas)
p = subprocess.run([sys.executable, str(script)], capture_output=True, text=True)
return p.returncode, p.stdout + p.stderr


class ContrasteBoardRegistro(unittest.TestCase):
def test_de_acuerdo_en_cerrado_pasa(self):
code, _ = correr([("GAP-001", "DONE", "🟢 RESOLVED")])
self.assertEqual(code, 0)

def test_de_acuerdo_en_abierto_pasa(self):
code, _ = correr([("GAP-001", "PENDING", "🟡 OPEN")])
self.assertEqual(code, 0)

def test_board_cerrado_y_registro_abierto_falla(self):
"""La forma exacta de COH-012, GAP-016, GAP-023 y GAP-025 el 2026-08-01."""
code, salida = correr([("GAP-001", "DONE", "🟡 OPEN")])
self.assertEqual(code, 1)
self.assertIn("GAP-001", salida)
self.assertIn("uno lo da por cerrado y el otro no", salida)

def test_registro_cerrado_y_board_abierto_falla(self):
"""La forma de GAP-020: cerrada en el registro y reabierta en el catálogo."""
code, salida = correr([("GAP-001", "PENDING", "🟢 RESOLVED")])
self.assertEqual(code, 1)
self.assertIn("GAP-001", salida)

def test_deferred_en_el_registro_no_cuenta_como_cerrado(self):
code, _ = correr([("GAP-001", "DONE", "🟡⏳ DEFERRED")])
self.assertEqual(code, 1)

def test_id_en_una_sola_superficie_no_es_contradiccion(self):
"""Las dos superficies sólo se solapan en parte. Comparar lo no compartido
convertiría cada id exclusivo en un fallo y haría el guard inservible."""
code, _ = correr([
("GAP-001", "DONE", "🟢 RESOLVED"),
("GAP-002", "DONE", None),
("GAP-003", None, "🟡 OPEN"),
])
self.assertEqual(code, 0)


if __name__ == "__main__":
unittest.main()
Loading
Loading