diff --git a/scripts/test-atuq-boveda-coherente.py b/scripts/test-atuq-boveda-coherente.py index eead8d62..3ea04fc9 100755 --- a/scripts/test-atuq-boveda-coherente.py +++ b/scripts/test-atuq-boveda-coherente.py @@ -73,13 +73,26 @@ TAWASUYU = os.environ.get("TAWASUYU", "/mnt/vvv/tawasuyu") RECETA_HOST = os.path.join(ROOT, "recipes/puriy-costura.toml") +# Lo que parece un verbo y no lo es: los ficheros que el manifiesto nombra. +NO_ES_VERBO = (".js", ".html", ".css", ".png", ".svg", ".json", ".txt", ".xpi", ".mjs") + + def verbos_que_manda_la_extension(): - """Los verbos salen del propio `fondo.js`, no de una lista escrita a mano acá. + """Los verbos salen del propio código de la extensión, no de una lista escrita a mano acá. Una lista paralela se desincroniza el día que alguien agrega un verbo y no toca este - fichero — y lo haría en el único sitio donde eso no puede verse.""" - fondo = leer("extensions/boveda/fondo.js") - return sorted(set(re.findall(r'verb:\s*"([a-z]+\.[a-z_]+)"', fondo))) + fichero — y lo haría en el único sitio donde eso no puede verse. + + ⚠ Y NO se busca `verb: "..."`, aunque la bóveda lo escriba así. Medido sobre las diez + extensiones: la de IA arma el mensaje con `postMessage({ id, verb: verbo, ... })`, o sea que + el verbo llega por variable y ese patrón no engancha NADA. Cero verbos encontrados y cero + verbos faltando se ven idénticos, y el guardián habría dicho que sí sin mirar nada. Por eso se + recogen todas las cadenas con forma de verbo y se descartan las que son nombres de fichero.""" + fuentes = [f for f in sorted(os.listdir(os.path.join(ATUQ, "extensions/boveda"))) + if f.endswith(".js")] + texto = "".join(leer(f"extensions/boveda/{f}") for f in fuentes) + crudos = set(re.findall(r'"([a-z][a-z0-9_]*\.[a-z][a-z0-9_]*)"', texto)) + return sorted(v for v in crudos if not v.endswith(NO_ES_VERBO)) def commit_pineado(): diff --git a/scripts/vigia-atuq-verbos.py b/scripts/vigia-atuq-verbos.py new file mode 100755 index 00000000..58679705 --- /dev/null +++ b/scripts/vigia-atuq-verbos.py @@ -0,0 +1,162 @@ +#!/usr/bin/env python3 +"""¿El host que la receta PINEA atiende los verbos que mandan las DIEZ extensiones? (SDD 26 §7) + + python3 scripts/vigia-atuq-verbos.py + python3 scripts/vigia-atuq-verbos.py --negative-control + +── EL FALLO QUE ESTO CAZA, Y POR QUÉ NO SE VE DE NINGUNA OTRA FORMA ────────────────────────── +Cada función de `atuq` que no es CSS es un VERBO del host nativo `puriy-costura`. El host vive en +tawasuyu y entra al corpus por `recipes/puriy-costura.toml`, que lo pinea por commit. O sea que hay +un cable con DOS puntas en dos repos distintos: la extensión manda `vault.match`, y que eso sea un +verbo o una cadena que nadie atiende lo decide un `commit =` de un TOML. + +Cuando las dos puntas no coinciden **no falla nada**. El manifiesto está, la política instala, +`connectNative` CONECTA —el host existe y contesta—, y lo que devuelve es: + + {"ok": false, "error": "verbo desconocido: vault.match"} + +El fondo de la extensión lee `r.ok !== true`, borra la insignia y se calla. Lo que el usuario ve es +un navegador sin esa función; lo que ve cualquier guardián de ficheros es todo en orden. Y la +distancia entre las dos puntas crece sola: escribir la extensión y subir el pin son dos unidades de +trabajo distintas, y la primera se commitea sin la segunda sin que nada proteste. + +Medido el 2026-09-15, que es por qué este vigía existe: la extensión `boveda` se commiteó por la +mañana con sus tres verbos ya escritos en tawasuyu (`bc6903f9e`) y la receta seguía pineada en +`e19bb0e5`, que es su ANCESTRO. Las otras nueve extensiones estaban bien — o sea que esto no se +podía deducir mirando «cuándo se tocó la receta por última vez». + +── CÓMO PREGUNTA, Y POR QUÉ ASÍ ────────────────────────────────────────────────────────────── +`git grep '"" =>' -- '*.rs'` sobre el clon de tawasuyu, LEYENDO DEL OBJETO: sin +checkout y sin tocar el árbol, que es compartido y siempre tiene ficheros en vuelo de otras +sesiones. El brazo de `match` es el sitio donde el verbo deja de ser texto y pasa a tener dueño. + +── LOS CONTROLES, PORQUE ESTO ES UN GREP QUE ESPERA CERO ───────────────────────────────────── +Un `grep` que devuelve cero puede ser una ausencia o un sitio equivocado, y las dos se ven igual. +Tres controles, uno por forma de mentir: + + POSITIVO `sct.observe` TIENE que aparecer en el commit pineado. Si no aparece, lo roto es + este vigía y lo dice así, en vez de acusar a la receta. + COBERTURA una extensión de la que no se extrae NINGÚN verbo se reporta, no se saltea: cero + verbos encontrados y cero verbos faltando se ven idénticos. + --negative-control le agrega un verbo inventado a cada extensión y exige que los detecte. + +Y si el clon de tawasuyu no está, esto NO pasa: «no se pudo comprobar» no es «está bien». + +── EL EXTRACTOR NO BUSCA `verb: "…"`, Y ESO ESTÁ MEDIDO ────────────────────────────────────── +La bóveda escribe `postMessage({ verb: "vault.match", … })`, pero la extensión de IA arma el +mensaje con `postMessage({ id, verb: verbo, … })` —el verbo llega por variable— y la de archivo +hace lo mismo. Un extractor que busque `verb:` seguido de comilla encuentra CERO en esas dos y las +declara sanas sin haber mirado nada. Por eso se recogen todas las cadenas con forma `ns.verbo` y se +descartan las que son nombres de fichero, que es lo único que se les parece. +""" +import os +import re +import subprocess +import sys + +ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +EXTS = os.path.join(ROOT, "recipes/atuq/extensions") +RECETA_HOST = os.path.join(ROOT, "recipes/puriy-costura.toml") +TAWASUYU = os.environ.get("TAWASUYU", "/mnt/vvv/tawasuyu") +CONTROL_POSITIVO = "sct.observe" +NO_ES_VERBO = (".js", ".html", ".css", ".png", ".svg", ".json", ".txt", ".xpi", ".mjs") + +# Extensiones que a propósito no le hablan al host: viven enteras dentro del navegador. Están +# NOMBRADAS y no descartadas por silencio, para que «no manda verbos» se distinga de «el extractor +# no encontró nada», que es el mismo cero con dos causas. +SIN_HOST = {"inicio", "proxy"} + + +def verbos_de(ext): + d = os.path.join(EXTS, ext) + texto = "" + for f in sorted(os.listdir(d)): + if f.endswith(".js"): + with open(os.path.join(d, f), encoding="utf-8") as fh: + texto += fh.read() + crudos = set(re.findall(r'"([a-z][a-z0-9_]*\.[a-z][a-z0-9_]*)"', texto)) + return sorted(v for v in crudos if not v.endswith(NO_ES_VERBO)) + + +def commit_pineado(): + with open(RECETA_HOST, encoding="utf-8") as f: + m = re.search(r'^commit\s*=\s*"([0-9a-f]{7,40})"', f.read(), re.M) + return m.group(1) if m else None + + +def atiende(commit, verbo): + return subprocess.run( + ["git", "-C", TAWASUYU, "grep", "-qF", f'"{verbo}" =>', commit, "--", "*.rs"], + capture_output=True, + ).returncode == 0 + + +def main(): + control = "--negative-control" in sys.argv + inventado = "host.este_verbo_no_existe" if control else None + fallas = [] + + commit = commit_pineado() + if not commit: + print(f"✗ {os.path.relpath(RECETA_HOST, ROOT)} no pinea ningún commit") + return 1 + if not os.path.isdir(os.path.join(TAWASUYU, ".git")): + print(f"✗ no hay clon de tawasuyu en {TAWASUYU}: esto queda SIN COMPROBAR, que no es " + f"lo mismo que estar bien (se cambia con TAWASUYU=/ruta)") + return 1 + if subprocess.run(["git", "-C", TAWASUYU, "cat-file", "-e", f"{commit}^{{commit}}"], + capture_output=True).returncode != 0: + print(f"✗ el clon no conoce el commit pineado {commit[:9]}: hace falta un `git fetch` " + f"antes de creerle a este vigía") + return 1 + + print(f"── los verbos de atuq contra el host pineado ──") + print(f" receta : {os.path.relpath(RECETA_HOST, ROOT)}") + print(f" commit : {commit[:9]} (clon: {TAWASUYU})\n") + + if not atiende(commit, CONTROL_POSITIVO): + print(f"✗ CONTROL ROTO: {CONTROL_POSITIVO} tampoco aparece en {commit[:9]}. El grep mira " + f"el sitio equivocado y ningún cero de este vigía prueba una ausencia.") + return 1 + print(f" ✓ control positivo: {CONTROL_POSITIVO} está en {commit[:9]} ⇒ el grep mide\n") + + if control: + print(f"⚠ control negativo: a cada extensión se le agrega {inventado}\n") + + for ext in sorted(os.listdir(EXTS)): + if not os.path.isdir(os.path.join(EXTS, ext)): + continue + verbos = verbos_de(ext) + if not verbos: + if ext in SIN_HOST: + print(f" {ext:<10} — no le habla al host, a propósito") + else: + print(f" {ext:<10} ✗ no se le extrajo NINGÚN verbo y debería tener: el extractor " + f"no está midiendo esta extensión") + fallas.append(f"{ext}: sin verbos extraídos") + continue + if inventado: + verbos = verbos + [inventado] + for v in verbos: + if atiende(commit, v): + print(f" {ext:<10} ✓ {v}") + else: + print(f" {ext:<10} ✗ {v} — el host pineado NO lo atiende: contesta «verbo " + f"desconocido» y la extensión lo lee como «no hay función»") + fallas.append(f"{ext}: {v}") + + if control: + if any(inventado in f for f in fallas): + print(f"\n✓ el control negativo lo detectó") + return 0 + print(f"\n✗ el control negativo NO lo detectó: este vigía no mide nada") + return 1 + if fallas: + print(f"\n✗ {len(fallas)} verbo(s) sin dueño en el host pineado") + return 1 + print(f"\n✓ las dos puntas del cable coinciden") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main())