docs: la regla del hilo de UI llega entera — el vigía, el auditor y la categoría que no va a un worker
El documento público era la mitad de junio: la regla, el patrón y cómo
diagnosticar, y después un checklist de apps que acá no significan nada. Le
faltaba todo lo aprendido desde entonces, incluida la documentación de una
función del propio runtime que se publica sin explicar.
Entra:
- **El vigía** (`llimphi-ui/src/eventloop/vigia.rs`), que ya viene prendido en
este repo y nadie sabía que existía: cronometra `init`/`update`/`view`/
`view_overlay`, avisa por stderr, cuesta 99 ns por llamada, y trae umbrales
distintos por función (100 ms para `update`, un cuadro para `view`) con sus
perillas `LLIMPHI_UI_SLOW_MS` y `LLIMPHI_UI_SLOW_VIEW_MS`.
- **El auditor estático**, ahora exportado como `scripts/auditar-hilo-ui.py`:
extrae el cuerpo de cada función del hilo de UI por balanceo de llaves y
distingue lo que despacha a un worker de lo que bloquea. Sale con 1: sirve de
gate. Sobre este repo da verde.
- **La tercera categoría**: lo accidentalmente cuadrático dentro de `view`, que
no se arregla mandándolo a un worker sino haciéndolo lineal, con la forma del
defecto y cómo se certifica.
- **La pregunta de la forma** (¿el costo de `view` crece con el dato?) y las tres
trampas de medirla.
Sale el checklist de apps del monorepo: son nombres que no existen en este repo.
diff --git a/COMPUTO-FUERA-DEL-HILO-UI.md b/COMPUTO-FUERA-DEL-HILO-UI.md
index 6ad2f47..4c0681e 100644
--- a/COMPUTO-FUERA-DEL-HILO-UI.md
+++ b/COMPUTO-FUERA-DEL-HILO-UI.md
@@ -1,9 +1,11 @@
# Cómputo pesado fuera del hilo de UI — regla dura de Llimphi
-> **PRIORIDAD URGENTE.** Patrón a aplicar a **todas** las apps Llimphi.
-> Origen: el "Not Responding" de cosmos (2026-05-31). Implementación de
-> referencia: `01_yachay/cosmos/cosmos-app-llimphi` (commits `added8b3`,
-> `9f221983`).
+> Regla dura del framework, a aplicar en **toda** app Llimphi. Origen: una app
+> de escritorio real que se quedó en "Not Responding" calculando efemérides
+> dentro de `update` (2026-05-31). Lo que sigue es lo aprendido desde entonces:
+> el patrón, el auditor estático que lo vuelve repetible, el vigía que mide en
+> vivo lo que ningún linter puede decidir, y la categoría de defecto que no se
+> arregla mandándola a un worker.
## La regla
@@ -98,25 +100,193 @@ Notas:
- En debug arranca como `cargo run` (binario `target/debug`); el release puede
ocultar el bug (float/overflow distintos).
-## Checklist — auditar y aplicar a cada app
-
-Buscar trabajo pesado en `init`/`update`/handlers y moverlo a worker:
-
-- [x] `01_yachay/cosmos/cosmos-app-llimphi` (referencia)
-- [ ] `00_unanchay/pluma/pluma-app`
-- [ ] `00_unanchay/pluma/pluma-editor-llimphi`
-- [ ] `00_unanchay/pluma/pluma-notebook-llimphi`
-- [ ] `00_unanchay/puriy/puriy-llimphi` (motor JS/render — alto riesgo)
-- [ ] `00_unanchay/khipu/khipu-app`
-- [ ] `00_unanchay/chaka/chaka-app-llimphi`
-- [ ] `01_yachay/dominium/dominium-app-llimphi`
-- [ ] `01_yachay/nakui/nakui-ui-llimphi`, `nakui-sheet-llimphi`, `nakui-explorer-llimphi`
-- [ ] `01_yachay/iniy/iniy-explorer-llimphi`
-- [ ] `01_yachay/tinkuy/tinkuy-llimphi` (simulación — alto riesgo)
-- [ ] `02_ruway/ayni/ayni-llimphi`
-- [ ] `02_ruway/chasqui/chasqui-explorer-llimphi`, `chasqui-broker-explorer-llimphi`
-- [ ] `02_ruway/nada`, `02_ruway/mirada/*-llimphi`
-- [ ] `pineal-*` (charting — revisar si el cómputo de series corre en update)
-
-(Lista de partida: `grep -rl 'llimphi-ui' --include=Cargo.toml`. Los widgets/
-modules/demos rara vez hacen cómputo pesado; foco en las apps de dominio.)
+## Barrido automático — `scripts/auditar-hilo-ui.py`
+
+El checklist a ojo no escala. `./scripts/auditar-hilo-ui.py [subárbol]` hace el
+barrido y es **repetible**: extrae el cuerpo de cada `fn init`/`update`/`view`/
+`view_overlay` por **balanceo de llaves** (un `File::open` en otra función del
+mismo archivo no cuenta) y ubica cada llamada bloqueante **dentro o fuera de la
+región de un `spawn(...)`**. Ese segundo paso no es cosmético: un `update` real
+es un `match` con decenas de arms, y basta que uno despache para que un grep por
+archivo declare sano al cuerpo entero mientras otro arm bloquea. Sale con código
+1 si encuentra algo, así que sirve de gate en CI.
+
+Dos detalles que se ganaron a los golpes:
+
+- **Enmascara comentarios y literales antes de analizar.** Sin eso el script se
+ marca a sí mismo: el comentario que explica *«este `fs::copy` es a propósito»*
+ matcheaba como si fuera código, y encima corría el número de línea reportado,
+ dejando la marca de excepción fuera de alcance. Un linter que denuncia su
+ propia documentación no sirve de gate.
+- **Excepciones explícitas y con motivo.** Un caso legítimamente sincrónico se
+ silencia con un comentario arriba de la llamada:
+ `// auditar-hilo-ui: intencional — <por qué>`. La marca obliga a escribir el
+ motivo al lado, porque un gate que se apaga sin explicar por qué es un gate
+ que en dos meses nadie sabe si sigue valiendo.
+
+Para confiar en un verde, el control negativo es barato: correr el script contra
+la versión **anterior** del archivo (`git show HEAD:… > /tmp/…`) y ver que sí
+encuentra lo que ya se arregló. Un linter que dejó de detectar también da verde.
+
+⚠️ **Dos límites, y el segundo es grande:**
+
+1. Sólo ve llamadas **directas**. Un helper que adentro hace IO pasa invisible.
+2. **No ve cómputo de CPU.** Detecta IO y `block_on`; un bucle que muele números
+ en `update` le pasa por al lado — y el caso que originó esta regla era
+ exactamente eso, no IO. Para esa mitad está el vigía.
+
+Sobre este repo el barrido sale limpio: los dos únicos hallazgos
+(`llimphi-anim-studio`, guardar y cargar) caen bajo «despachan a un worker
+(correcto, informativo)», que es la clasificación distinta, no la desaparición
+de la detección.
+
+## El vigía del hilo de UI — el límite deja de necesitar ojo
+
+Decidir **estáticamente** si un bucle es caro es el problema de la parada con
+sombrero: ningún linter lo va a cerrar. Pero **medirlo en vivo sí**, y sale casi
+gratis, porque el runtime ya pasa por un solo lugar en cada `update`.
+
+`llimphi-ui/src/eventloop/vigia.rs` cronometra cada `App::update`, cada
+`App::init`, cada `App::view` y cada `App::view_overlay`. Si uno tarda más que su
+umbral, lo dice por stderr:
+
+```
+llimphi: update bloqueó el hilo de UI 342 ms (src/eventloop/input.rs:189) — el
+trabajo pesado va a un worker (ver COMPUTO-FUERA-DEL-HILO-UI.md)
+```
+
+Cuatro decisiones que valen más que el código:
+
+- **Encendido por defecto.** Si hiciera falta una feature o una env, volvería a
+ depender de que alguien se acuerde. El costo es un par
+ `Instant::now()` + `elapsed()` por llamada: **99 ns medidos**. Aun con mil
+ eventos por segundo son 0,1 ms/s. `LLIMPHI_UI_SLOW_MS=<ms>` cambia el umbral,
+ `=0` lo apaga.
+- **Umbral 100 ms para `update`/`init`.** Un cuadro a 60 Hz son 16,7 ms. Con 100
+ no se denuncia un `update` que se pasó de un cuadro —eso es ruido— sino uno que
+ se comió seis: ahí ya no es una animación que salta, es la ventana congelada.
+- **Habla sólo cuando aprende algo**: la primera vez que un sitio pasa el umbral,
+ y después sólo si empeora. Un `update` lento suele serlo en cada evento, y una
+ línea por vez volvería el aviso invisible — la forma más común en que un aviso
+ deja de servir.
+- **`#[track_caller]` nombra el sitio** (`input.rs:189` = teclado, `mod.rs:349` =
+ canal de ticks) sin etiquetar a mano los 33 sitios de llamada.
+
+`init` lleva aviso propio: corre dentro de `resumed`, o sea **después** de crear
+la ventana, así que un cómputo pesado ahí congela una ventana que el usuario ya
+está viendo.
+
+### La función que es peor que `update`: `view`
+
+`view` corre **en cada cuadro**, y el runtime no memoiza la llamada a nivel app
+(los nodos `memo` cachean *subárboles*, no la llamada). El costo tiene otra
+forma:
+
+> un `update` lento congela la ventana **una vez**; una `view` lenta le pone un
+> **techo de fps** mientras esté abierta.
+
+Tres decisiones propias:
+
+- **Umbral más bajo: un cuadro a 60 Hz (16,7 ms)**, y sale de una medición, no de
+ la teoría: `LLIMPHI_PERF=1 stress_idle` da `view` en **190–326 µs de media y
+ 727 µs de p95** sobre 987 nodos. El umbral queda 23× arriba del peor cuadro de
+ un árbol normal —no puede haber falso positivo por un pico— y una app con una
+ `view` genuinamente pesada todavía tiene 3–5× de aire. Hay un test que fija ese
+ margen, para que nadie lo baje sin volver a medir.
+ `LLIMPHI_UI_SLOW_VIEW_MS=<ms>` lo cambia, `=0` lo apaga.
+- **El aviso traduce ms a fps**, que es el número accionable: «84 ms» no dice
+ nada, «techo de 12 fps» sí.
+- **Costo**: el mismo par de 99 ns, dos veces por cuadro. 12 µs por segundo a
+ 60 fps.
+
+Verificado punta a punta contra el runtime y no sólo por unit test —que es lo que
+distingue «el envoltorio anda» de «el runtime lo usa»—:
+`LLIMPHI_UI_SLOW_VIEW_MS=0.05 cargo run -p llimphi-ui --example stress_idle`
+denuncia por stderr; con el umbral de fábrica la misma corrida de 12 s no dice
+**nada**. Y de paso se ve la regla de callarse trabajando bajo presión de cuadro:
+~720 cuadros por encima del umbral forzado, **4 líneas**.
+
+## La tercera categoría: no es IO, y tampoco va a un worker
+
+Este documento nació con dos categorías: **IO bloqueante** (que el auditor ve) y
+**cómputo pesado** (que sólo el vigía ve). Las dos se arreglan igual: al worker.
+Hay una tercera, y no se arregla así.
+
+Barriendo una app de edición de documentos aparecieron **cinco** funciones que
+corrían dentro de `view` —o sea en **cada cuadro**— y eran **accidentalmente
+cuadráticas**. Con 1.600 párrafos: 177.517 µs → 161 µs (**1.105×**), 96.252 µs →
+1.057 µs (**91×**), 54.472 µs → 1.624 µs (**33,5×**), 49.100 µs → 80 µs
+(**642×**).
+
+**La forma es siempre la misma**, y por eso se reconoce: una función que resuelve
+*una* consulta recorriendo *todo* —partir el texto entero en párrafos, contar
+desde el principio, `position()` barriendo un `Vec`— llamada **dentro de un
+bucle** que recorre las líneas o los elementos. Cada función suelta es correcta y
+barata. El bucle es el que multiplica.
+
+**Y por eso no van a un worker.** Mandar 177 ms de trabajo cuadrático a otro hilo
+no arregla nada: sigue siendo cuadrático, y encima ahora hay que sincronizarlo.
+Lo que corresponde es **hacerlo lineal**: resolver la consulta para todos los
+elementos en una pasada, antes del bucle. Las funciones de a una **se dejan
+intactas**: para una consulta suelta son lo correcto, y sirven de oráculo.
+
+**Cómo se certifica.** Dos tests por arreglo, y ninguno mira una imagen:
+
+1. **Equivalencia** contra la función de a una, que quedó sin tocar. Elemento por
+ elemento, con guarda de arnés sobre cuántos se compararon y un caso que
+ ejercite lo que podría desalinearse.
+2. **Forma, no milisegundos.** No se fija un tiempo —depende de la máquina— sino
+ que el **costo por elemento no crezca con el tamaño**. Medido a dos tallas
+ (×16 de documento): antes crecía ×10 a ×13, después queda entre ×0,7 y ×1,4.
+
+**Qué lo habría encontrado antes.** El vigía, sin dudarlo: los cuatro medidos
+pasan los 16,7 ms del umbral de `view` por uno o dos órdenes de magnitud. El
+auditor estático **no**, y no hay forma de que lo haga. Se probó un barrido
+heurístico («un `.position(`/`.split(` adentro de un `for`, dentro de una función
+de vista»): **16 sitios, 15 falsos positivos**. No se dejó como gate — un linter
+con 94 % de falsos positivos entrena a ignorarlo, que es peor que no tenerlo.
+
+O sea que la defensa contra esta categoría es **correr la app con el vigía
+prendido**, no un script.
+
+## La pregunta que hay que hacerle a cada app
+
+> **¿El costo de `view()` por cuadro depende del tamaño de los datos?**
+
+No «¿cuánto tarda?», sino «¿crece?». Se contesta con **dos tallas** del mismo
+dato (una chica, una 8× más grande), midiendo el **cociente**.
+
+### Las tres trampas, todas pisadas el mismo día
+
+1. **Un techo absoluto se pudre en silencio.** «`view` < medio cuadro» pasaba
+ **mientras el costo crecía con el documento**: seguía verde hasta que alguien
+ abriera uno más grande. Un gate de forma no envejece.
+2. **Árbol acotado ≠ trabajo acotado.** Una app dio **462 nodos fijos** en las dos
+ tallas —la virtualización del editor funciona— y aun así el tiempo crecía: una
+ función recorría el texto entero por cuadro para producir esos 462 nodos.
+ *Contar nodos no contesta la pregunta.*
+3. **Una medición suelta no decide nada.** La misma forma dio ×6,29 (tallas en
+ tandas separadas), ×1,02–1,70 (nueve rondas alternadas) y ×1,03–1,08
+ (veinticinco rondas alternadas). Las dos primeras eran ruido: una exageraba el
+ problema seis veces. **Alternar las dos tallas en el mismo bucle y tomar el
+ mínimo**, nunca medirlas en tandas.
+
+### Definición de terminado, por app
+
+- Un test en el crate que arma la vista real con dos tallas y afirma la forma.
+- Gate puesto **sobre lo medido**, con el número en el comentario.
+- Guarda de arnés: que las dos vistas tengan nodos de verdad (comparar dos
+ árboles vacíos da «plano» sin significar nada).
+- Si crece: desglose por fase impreso, para que quien lea el fallo no repita la
+ investigación.
+
+## El procedimiento, corto
+
+Para cada app que escribas sobre Llimphi:
+
+1. `./scripts/auditar-hilo-ui.py <ruta de la app>` → cero hallazgos sincrónicos.
+2. Usarla con el **vigía de fábrica** (viene prendido) y ejercitar el caso
+ grande, no el de demo: el dato que crece sin techo es donde vive el defecto.
+3. Un test de **forma** de `view` con dos tallas, si el dato de la app crece.
+4. Lo que quede legítimamente sincrónico se marca en el código con
+ `// auditar-hilo-ui: intencional — <por qué>`.
diff --git a/scripts/auditar-hilo-ui.py b/scripts/auditar-hilo-ui.py
new file mode 100755
index 0000000..7361e26
--- /dev/null
+++ b/scripts/auditar-hilo-ui.py
@@ -0,0 +1,259 @@
+#!/usr/bin/env python3
+"""
+scripts/auditar-hilo-ui.py — cazador de trabajo bloqueante en el hilo de UI.
+
+Hace cumplir la regla de `COMPUTO-FUERA-DEL-HILO-UI.md`:
+ninguna de las funciones que la app corre en el hilo de UI —`init`, `update`,
+`view`, `view_overlay`— debe ejecutar trabajo pesado **síncrono**, o la ventana
+deja de repintar y aparece el "Not Responding".
+
+Por qué un script y no un grep: un `File::open` en el mismo *archivo* que un
+`update()` no prueba nada — puede vivir en otra función. Acá el cuerpo de cada
+una se extrae por **balanceo de llaves**, así que sólo cuenta
+lo que de verdad está adentro. Y si ese cuerpo despacha a un worker
+(`handle.spawn`, `spawn_periodic`, o un `std::thread::spawn` crudo) se informa
+como correcto, no como riesgo: es justamente el patrón que pide el documento.
+
+Tildar apps a mano no escala; esto lo vuelve repetible — se puede correr
+después de cada cambio y sobre apps nuevas, y sirve de gate en CI.
+
+Uso:
+ ./scripts/auditar-hilo-ui.py # TODO el repo (default)
+ ./scripts/auditar-hilo-ui.py widgets # un subárbol puntual
+
+Sale con código 1 si encontró algún caso sincrónico (sirve de gate).
+"""
+import re
+import sys
+import pathlib
+
+RAIZ = pathlib.Path(__file__).resolve().parent.parent
+
+# **El default es TODO el repo, no una lista.**
+#
+# Hasta 2026-08-05 acá había trece rutas escritas a mano —el checklist de
+# COMPUTO-FUERA-DEL-HILO-UI.md— y eso tenía un agujero silencioso: una app nueva
+# no entraba a la lista, así que nunca se auditaba, y nadie se enteraba porque el
+# script salía en verde. Auditando el repo entero apareció **suilad**, una app
+# que la lista no tenía: bloqueaba en `init` conectando al daemon de embeddings y
+# en `update` volcando un adjunto a disco.
+#
+# Auditar todo cuesta poco (segundos) y da poco ruido: sobre el repo completo
+# salen cinco marcas, de las que se descartan dos leyéndolas. Una lista que hay
+# que acordarse de actualizar es peor que un barrido con algún falso positivo.
+#
+# Se sigue pudiendo acotar a un subárbol pasándolo como argumento, que es lo
+# cómodo mientras se arregla algo puntual.
+RAIZ_POR_DEFECTO = ['.']
+
+# **Las cuatro funciones que la app corre en el hilo de UI**, no dos.
+#
+# Hasta 2026-08-05 acá se auditaban sólo `update` e `init`, y eso dejaba afuera
+# a `view` — que es la peor de todas, porque corre en **cada cuadro**
+# (`eventloop/redraw.rs` la llama sin memoizar el resultado a nivel app). Un
+# `File::open` en `update` congela la ventana una vez; el mismo `File::open` en
+# `view` la congela sesenta veces por segundo.
+#
+# Al agregarlas el barrido salió en **cero hallazgos** sobre el repo entero, o
+# sea que hoy no arregla nada: es una guarda para que no aparezca el primero. Lo
+# que sí puede haber ahí —cómputo de CPU, que es justo el caso que originó esta
+# regla— ningún script lo va a ver; para eso está el vigía en vivo
+# (`llimphi-ui/src/eventloop/vigia.rs`), que desde el mismo día cronometra
+# también `view` y `view_overlay`.
+FUNCIONES_DEL_HILO_UI = ('update', 'init', 'view', 'view_overlay')
+
+# Trabajo que puede pasar de unos pocos ms. No pretende ser exhaustivo: es la
+# lista de lo que ya mordió. Agregar un caso = una línea.
+BLOQUEANTE = re.compile(r"""
+ \bfs::(read|write|read_to_string|read_dir|copy|remove_dir|remove_file)
+ | \bFile::(open|create)
+ | \bblock_on\b
+ | \breqwest::blocking\b
+ | \.output\(\)
+ | \bstd::thread::sleep\b
+""", re.X)
+
+# Lo que manda el trabajo afuera. `std::thread::spawn` cuenta: es un worker
+# igual, aunque no use el `Handle` de Llimphi.
+#
+# OJO — no alcanza con preguntar «¿este cuerpo contiene un spawn?»: un `update`
+# real es un `match` con decenas de arms, y basta que UNO despache para que el
+# cuerpo entero parezca sano mientras otro arm bloquea. (Pasó con `khipu-app`:
+# un arm hace `h.spawn(…)` y otro llama `rt.block_on(node.descubrir())` pelado.)
+# Por eso se calcula la **región** de cada spawn —de la llamada hasta su
+# paréntesis de cierre, que cubre tanto `spawn(move || { … })` como
+# `spawn(move || expr)`— y cada hallazgo se ubica dentro o fuera.
+SPAWN = re.compile(r'(?:handle|h|rt|self)?\.?\b(?:spawn|spawn_periodic|spawn_blocking)\s*\(')
+
+
+def regiones_worker(cuerpo: str):
+ """[(inicio, fin)] de cada llamada a spawn(...), por balanceo de paréntesis."""
+ fuera = []
+ for m in SPAWN.finditer(cuerpo):
+ i = m.end() - 1 # el '(' de la llamada
+ prof, j = 0, i
+ while j < len(cuerpo):
+ if cuerpo[j] == '(':
+ prof += 1
+ elif cuerpo[j] == ')':
+ prof -= 1
+ if prof == 0:
+ break
+ j += 1
+ fuera.append((m.start(), j))
+ return fuera
+
+
+def enmascarar(texto: str) -> str:
+ """Devuelve el texto con comentarios y literales de cadena reemplazados por
+ espacios, conservando longitud y saltos de línea (así los offsets y los
+ números de línea siguen valiendo sobre el original).
+
+ Hace falta de verdad: sin esto el script se marca a sí mismo. Un comentario
+ que dice «este `fs::copy` es a propósito» matcheaba como si fuera código, y
+ encima corría el número de línea reportado hasta el comentario — dejando la
+ marca de excepción, escrita más abajo, fuera de alcance.
+ """
+ out = list(texto)
+ i, n = 0, len(texto)
+ while i < n:
+ c = texto[i]
+ if c == '/' and i + 1 < n and texto[i + 1] == '/':
+ while i < n and texto[i] != '\n':
+ out[i] = ' '
+ i += 1
+ elif c == '/' and i + 1 < n and texto[i + 1] == '*':
+ while i < n and not (texto[i] == '*' and i + 1 < n and texto[i + 1] == '/'):
+ if texto[i] != '\n':
+ out[i] = ' '
+ i += 1
+ for _ in range(2):
+ if i < n:
+ out[i] = ' '
+ i += 1
+ elif c == '"':
+ out[i] = ' '
+ i += 1
+ while i < n and texto[i] != '"':
+ if texto[i] == '\\' and i + 1 < n:
+ out[i] = ' '
+ i += 1
+ if i < n:
+ if texto[i] != '\n':
+ out[i] = ' '
+ i += 1
+ if i < n:
+ out[i] = ' '
+ i += 1
+ else:
+ i += 1
+ return ''.join(out)
+
+
+def cuerpos(texto: str, nombre: str):
+ """Cuerpos de las `fn <nombre>` del archivo, por balanceo de llaves."""
+ for m in re.finditer(r'\bfn\s+' + nombre + r'\s*[(<]', texto):
+ i = texto.find('{', m.end())
+ if i < 0:
+ continue
+ prof, j = 0, i
+ while j < len(texto):
+ if texto[j] == '{':
+ prof += 1
+ elif texto[j] == '}':
+ prof -= 1
+ if prof == 0:
+ break
+ j += 1
+ # La línea del `{`, **no la del `fn`**. Parece un detalle y no lo es: la
+ # firma de un `update` real ocupa varias líneas, así que contar desde el
+ # `fn` corría todos los números reportados hacia arriba por ese tanto. Se
+ # notó porque rompía la marca de excepción: el script la busca en las
+ # líneas de **arriba** del hallazgo, y con el número corrido la marca
+ # —escrita justo encima de la llamada— quedaba abajo de la ventana y no
+ # se veía. O sea que la vía para documentar una excepción no funcionaba.
+ yield texto[i:j + 1], texto[:i].count('\n') + 1
+
+
+# Marca de excepción. Se pone en un comentario **arriba** de la llamada, y
+# obliga a escribir el motivo al lado — un gate que se apaga sin explicar por
+# qué es un gate que en dos meses nadie sabe si sigue valiendo.
+#
+# // auditar-hilo-ui: intencional — persist() pisa esta misma ruta
+MARCA = 'auditar-hilo-ui: intencional'
+LINEAS_DE_GRACIA = 14
+
+
+def exceptuado(texto_archivo: str, linea: int) -> bool:
+ """¿Hay una marca de excepción en las líneas justo arriba de `linea`?"""
+ lineas = texto_archivo.splitlines()
+ desde = max(0, linea - 1 - LINEAS_DE_GRACIA)
+ return any(MARCA in l for l in lineas[desde:linea])
+
+
+def auditar(subarboles):
+ sincronicos, con_worker = [], []
+ for app in subarboles:
+ base = RAIZ / app
+ if not base.is_dir():
+ print(f' (no existe: {app})', file=sys.stderr)
+ continue
+ for f in sorted(base.rglob('*.rs')):
+ s = str(f)
+ # Los examples son demos, no la app; los tests tampoco pintan.
+ if '/target/' in s or '/examples/' in s or '/tests/' in s:
+ continue
+ try:
+ original = f.read_text(encoding='utf-8', errors='ignore')
+ except OSError:
+ continue
+ # El análisis corre sobre el texto SIN comentarios ni cadenas; la
+ # marca de excepción, en cambio, se busca sobre el original (vive
+ # justamente en un comentario).
+ t = enmascarar(original)
+ for fn in FUNCIONES_DEL_HILO_UI:
+ for cuerpo, linea_fn in cuerpos(t, fn):
+ workers = regiones_worker(cuerpo)
+ base_ln = linea_fn # línea donde arranca la fn
+ for m in BLOQUEANTE.finditer(cuerpo):
+ pos = m.start()
+ ln = base_ln + cuerpo[:pos].count('\n')
+ if exceptuado(original, ln):
+ continue
+ dentro = any(a <= pos <= b for a, b in workers)
+ # Relativa al repo cuando se puede; un subárbol de
+ # afuera (probar contra una copia vieja, p.ej.) se
+ # imprime tal cual en vez de reventar.
+ try:
+ ruta = str(f.relative_to(RAIZ))
+ except ValueError:
+ ruta = str(f)
+ reg = (ruta, ln, fn, m.group(0).strip())
+ (con_worker if dentro else sincronicos).append(reg)
+ return sincronicos, con_worker
+
+
+def main():
+ subarboles = sys.argv[1:] or RAIZ_POR_DEFECTO
+ sincronicos, con_worker = auditar(subarboles)
+
+ if con_worker:
+ print('== despachan a un worker (correcto, informativo) ==')
+ for ruta, linea, fn, hit in con_worker:
+ print(f' {ruta}:{linea} fn {fn}() · {hit}')
+ print()
+
+ if sincronicos:
+ print('== SINCRONICO EN EL HILO DE UI ==')
+ for ruta, linea, fn, hit in sincronicos:
+ print(f' {ruta}:{linea} fn {fn}() · {hit}')
+ print(f'\n{len(sincronicos)} caso(s). Patrón para arreglarlo: '
+ 'COMPUTO-FUERA-DEL-HILO-UI.md §«El patrón».')
+ return 1
+
+ print("== ningún trabajo bloqueante dentro de update()/init()/view() ==")
+ return 0
+
+
+if __name__ == '__main__':
+ sys.exit(main())