github-pages-auditor 1.2.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,104 @@
1
+ import os
2
+ import sys
3
+
4
+ from rich.console import Console
5
+
6
+ from . import creditos
7
+ from .audit import ejecutar_auditoria
8
+ from .config import Config, parse_args
9
+ from .errors import ErrorAuditoria
10
+ from .formatters import a_json, bloque_markdown, inyectar
11
+ from .report import generar_txt, renderizar_consola
12
+ from .rules import disparadas, evaluar
13
+
14
+ EXIT_OK, EXIT_HALLAZGOS, EXIT_ERROR, EXIT_SIN_REPOS, EXIT_CTRL_C = 0, 1, 2, 3, 130
15
+
16
+
17
+ def _contenido(config: Config, reporte, hallazgos, activadas) -> str:
18
+ if config.formato == "json":
19
+ # En stdout, JSON solo ASCII: sobrevive a cualquier codificación de consola (p. ej. cp1252 en Windows).
20
+ return a_json(reporte, hallazgos, config.fail_on, activadas, solo_ascii=config.salida_estandar)
21
+ if config.formato == "md":
22
+ return bloque_markdown(reporte)
23
+ return generar_txt(reporte)
24
+
25
+
26
+ def _escribir_stdout(texto: str) -> None:
27
+ """Escribe en UTF-8 aunque la consola use otra codificación (cp1252, etc.)."""
28
+ buffer = getattr(sys.stdout, "buffer", None)
29
+ if buffer is not None:
30
+ buffer.write(texto.encode("utf-8"))
31
+ buffer.flush()
32
+ else: # stdout sustituido (tests)
33
+ sys.stdout.write(texto)
34
+ sys.stdout.flush()
35
+
36
+
37
+ def _carpeta_ok(ruta: str) -> bool:
38
+ return os.path.isdir(os.path.dirname(os.path.abspath(ruta)))
39
+
40
+
41
+ def main(args=None) -> int:
42
+ """Devuelve 0 (ok), 1 (--fail-on disparado), 2 (error), 3 (sin repositorios) o 130 (Ctrl+C)."""
43
+ config = parse_args(args) # --help, --version y errores de uso salen aquí
44
+ # Con '-o -' el informe va a stdout y TODA la interfaz pasa a stderr (stdout queda limpio para pipes).
45
+ console = Console(no_color=config.sin_color, stderr=config.salida_estandar, quiet=config.quiet)
46
+ err = Console(stderr=True, no_color=config.sin_color)
47
+
48
+ if config.archivo_salida and not config.salida_estandar and not _carpeta_ok(config.archivo_salida):
49
+ carpeta = os.path.dirname(os.path.abspath(config.archivo_salida))
50
+ err.print(f"[bold red]❌ La carpeta de salida no existe: '{carpeta}'[/bold red]")
51
+ return EXIT_ERROR
52
+ if config.inject and not os.path.isfile(config.inject):
53
+ err.print(f"[bold red]❌ No existe el archivo para --inject: '{config.inject}'[/bold red]")
54
+ return EXIT_ERROR
55
+
56
+ try:
57
+ reporte = ejecutar_auditoria(config, console=console)
58
+ except ErrorAuditoria as e:
59
+ err.print(f"[bold red]❌ {e}[/bold red]")
60
+ return EXIT_ERROR
61
+ except KeyboardInterrupt:
62
+ err.print("\n[bold yellow]⚠️ Operación cancelada por el usuario.[/bold yellow]")
63
+ return EXIT_CTRL_C
64
+
65
+ if not reporte.repos:
66
+ err.print("[bold red]❌ No hay repositorios que analizar (usuario sin repos públicos o todos filtrados).[/bold red]")
67
+ return EXIT_SIN_REPOS
68
+
69
+ hallazgos = evaluar(reporte.repos)
70
+ activadas = disparadas(hallazgos, config.fail_on)
71
+
72
+ renderizar_consola(reporte, console=console)
73
+ try:
74
+ if config.archivo_salida:
75
+ contenido = _contenido(config, reporte, hallazgos, activadas)
76
+ if config.salida_estandar:
77
+ _escribir_stdout(contenido)
78
+ else:
79
+ with open(config.archivo_salida, "w", encoding="utf-8") as f:
80
+ f.write(contenido)
81
+ console.print(f"\n[bold green]✅ Reporte exportado a '[white]{config.archivo_salida}[/white]'[/bold green]")
82
+ if config.inject:
83
+ with open(config.inject, encoding="utf-8") as f:
84
+ original = f.read()
85
+ nuevo = inyectar(original, bloque_markdown(reporte, incluir_forks=False))
86
+ with open(config.inject, "w", encoding="utf-8") as f:
87
+ f.write(nuevo)
88
+ console.print(f"[bold green]✅ Tabla de demos actualizada en '[white]{config.inject}[/white]'[/bold green]")
89
+ except ErrorAuditoria as e:
90
+ err.print(f"[bold red]❌ {e}[/bold red]")
91
+ return EXIT_ERROR
92
+ except OSError as e:
93
+ err.print(f"[bold red]❌ No se pudo escribir la salida: {e}[/bold red]")
94
+ return EXIT_ERROR
95
+
96
+ console.print(f"[dim]{creditos()}[/dim]", highlight=False, soft_wrap=True)
97
+ if activadas:
98
+ err.print(f"[bold red]❌ --fail-on disparado por: {', '.join(activadas)}[/bold red]")
99
+ return EXIT_HALLAZGOS
100
+ return EXIT_OK
101
+
102
+
103
+ if __name__ == "__main__":
104
+ sys.exit(main())
@@ -0,0 +1,123 @@
1
+ from dataclasses import dataclass, field
2
+ from typing import List, Optional
3
+
4
+ ESTADO_OK = "ok" # la web responde y parece una página real
5
+ ESTADO_ROTA = "roto" # se esperaba una web (Pages activadas) pero falla
6
+ ESTADO_NINGUNA = "ninguna" # no hay Pages (o no se pueden afirmar)
7
+
8
+
9
+ @dataclass
10
+ class PagesHealth:
11
+ """Resultado de inspeccionar una URL de GitHub Pages."""
12
+
13
+ estado: str
14
+ url: str
15
+ url_final: Optional[str] = None
16
+ http_status: Optional[int] = None
17
+ ms: Optional[int] = None
18
+ titulo: Optional[str] = None
19
+ problemas: List[str] = field(default_factory=list) # errores: la página está rota
20
+ avisos: List[str] = field(default_factory=list) # avisos: funciona pero mejorable
21
+
22
+ @property
23
+ def ok(self) -> bool:
24
+ return self.estado == ESTADO_OK
25
+
26
+
27
+ @dataclass
28
+ class RepoInfo:
29
+ """Resultado de auditar un repositorio.
30
+
31
+ ``tiene_descripcion``, ``es_fork`` y ``esta_archivado`` valen ``None`` cuando
32
+ no se han podido determinar (no se listan en el informe).
33
+ """
34
+
35
+ nombre: str
36
+ tiene_pages: bool = False
37
+ url_pages: Optional[str] = None
38
+ tiene_releases: bool = False
39
+ tiene_readme: bool = False
40
+ tiene_licencia: bool = False
41
+ tiene_gitignore: bool = False
42
+ tiene_descripcion: Optional[bool] = None
43
+ es_fork: Optional[bool] = None
44
+ esta_archivado: Optional[bool] = None
45
+ descripcion: Optional[str] = None
46
+ pages_salud: Optional[PagesHealth] = None
47
+ error: Optional[str] = None
48
+
49
+ @property
50
+ def pages_rota(self) -> bool:
51
+ return self.pages_salud is not None and self.pages_salud.estado == ESTADO_ROTA
52
+
53
+ @property
54
+ def pages_viva(self) -> bool:
55
+ if not self.tiene_pages or not self.url_pages:
56
+ return False
57
+ return self.pages_salud is None or self.pages_salud.ok
58
+
59
+
60
+ @dataclass
61
+ class AuditReport:
62
+ usuario: str
63
+ dominio_base: str
64
+ proveedor: str
65
+ repos: List[RepoInfo] = field(default_factory=list)
66
+ modo: str = "web" # "api" | "web" (identificador estable para el JSON)
67
+
68
+ @property
69
+ def total_repos(self) -> int:
70
+ return len(self.repos)
71
+
72
+ @property
73
+ def _analizados(self) -> List[RepoInfo]:
74
+ """Repos sin error: de un repo que falló no se puede afirmar nada."""
75
+ return [r for r in self.repos if not r.error]
76
+
77
+ @property
78
+ def con_error(self) -> List[RepoInfo]:
79
+ return [r for r in self.repos if r.error]
80
+
81
+ @property
82
+ def con_pages(self) -> List[RepoInfo]:
83
+ return [r for r in self._analizados if r.pages_viva]
84
+
85
+ @property
86
+ def pages_con_avisos(self) -> List[RepoInfo]:
87
+ return [r for r in self._analizados if r.pages_viva and r.pages_salud and r.pages_salud.avisos]
88
+
89
+ @property
90
+ def pages_rotas(self) -> List[RepoInfo]:
91
+ return [r for r in self._analizados if r.pages_rota]
92
+
93
+ @property
94
+ def con_releases(self) -> List[RepoInfo]:
95
+ return [r for r in self._analizados if r.tiene_releases]
96
+
97
+ @property
98
+ def sin_nada(self) -> List[RepoInfo]:
99
+ return [r for r in self._analizados if not r.tiene_pages and not r.tiene_releases]
100
+
101
+ @property
102
+ def sin_readme(self) -> List[RepoInfo]:
103
+ return [r for r in self._analizados if not r.tiene_readme]
104
+
105
+ @property
106
+ def sin_licencia(self) -> List[RepoInfo]:
107
+ return [r for r in self._analizados if not r.tiene_licencia]
108
+
109
+ @property
110
+ def sin_gitignore(self) -> List[RepoInfo]:
111
+ return [r for r in self._analizados if not r.tiene_gitignore]
112
+
113
+ @property
114
+ def sin_descripcion(self) -> List[RepoInfo]:
115
+ return [r for r in self._analizados if r.tiene_descripcion is False]
116
+
117
+ @property
118
+ def forks(self) -> List[RepoInfo]:
119
+ return [r for r in self._analizados if r.es_fork]
120
+
121
+ @property
122
+ def archivados(self) -> List[RepoInfo]:
123
+ return [r for r in self._analizados if r.esta_archivado]
@@ -0,0 +1,51 @@
1
+ from typing import Optional
2
+ from urllib.parse import urlparse
3
+
4
+ import requests
5
+
6
+
7
+ def es_raiz(usuario: str, nombre_repo: str) -> bool:
8
+ """Solo ``<usuario>.github.io`` es el sitio raíz.
9
+
10
+ Un repo llamado igual que el usuario es el README del perfil, no un sitio.
11
+ """
12
+ return nombre_repo.lower() == f"{usuario.lower()}.github.io"
13
+
14
+
15
+ def construir_url_pages(usuario: str, nombre_repo: str, dominio_base: str) -> str:
16
+ dominio_base = dominio_base.rstrip("/")
17
+ if es_raiz(usuario, nombre_repo):
18
+ return dominio_base
19
+ return f"{dominio_base}/{nombre_repo}/"
20
+
21
+
22
+ def resolver_pages(session: requests.Session, url: str) -> Optional[str]:
23
+ """Devuelve la URL efectiva de la página si existe, o ``None``.
24
+
25
+ - Respuesta 200 en el mismo dominio: la URL pedida.
26
+ - Redirección desde ``*.github.io`` a otro dominio con 200: es el dominio
27
+ personalizado que GitHub tiene configurado en esas Pages, y se devuelve
28
+ la URL final (así se descubre el dominio real sin indicarlo con -w).
29
+ - Cualquier otra redirección a otro dominio se descarta (p. ej. parking).
30
+ """
31
+ try:
32
+ response = session.get(url, allow_redirects=True, stream=True)
33
+ except requests.RequestException:
34
+ return None
35
+ try:
36
+ if response.status_code != 200:
37
+ return None
38
+ final = getattr(response, "url", None) or url
39
+ host_pedido = urlparse(url).netloc.lower()
40
+ host_final = urlparse(final).netloc.lower()
41
+ if host_final == host_pedido:
42
+ return url
43
+ if host_pedido.endswith(".github.io"):
44
+ return final
45
+ return None
46
+ finally:
47
+ response.close()
48
+
49
+
50
+ def comprobar_pages(session: requests.Session, url: str) -> bool:
51
+ return resolver_pages(session, url) is not None
@@ -0,0 +1,101 @@
1
+ """Salud de GitHub Pages: más allá de "responde 200".
2
+
3
+ Detecta 404 / sin ``index.html``, errores TLS, timeouts, redirecciones a otro
4
+ dominio o en bucle, páginas vacías o que no son HTML, y lentitud.
5
+
6
+ ``esperada=True`` significa "sabemos que estas Pages deberían existir"
7
+ (modo API: ``has_pages``). Entonces cualquier fallo es ``roto``. Con
8
+ ``esperada=False`` (modo web) un fallo solo significa "no hay Pages".
9
+ """
10
+ import html as _html
11
+ import re
12
+ import time
13
+ from typing import Optional
14
+ from urllib.parse import urlparse
15
+
16
+ import requests
17
+
18
+ from .models import ESTADO_NINGUNA, ESTADO_OK, ESTADO_ROTA, PagesHealth
19
+
20
+ LIMITE_BYTES = 65536
21
+ MAX_REDIRECCIONES = 5
22
+ UMBRAL_LENTA_MS = 3000
23
+
24
+ _TITULO = re.compile(r"<title[^>]*>(.*?)</title>", re.IGNORECASE | re.DOTALL)
25
+ _HTML = re.compile(r"<(!doctype\s+html|html|head|body)\b", re.IGNORECASE)
26
+
27
+
28
+ def _leer_inicio(response) -> str:
29
+ """Lee solo el primer fragmento del cuerpo (no descarga páginas enormes)."""
30
+ try:
31
+ trozo = next(iter(response.iter_content(LIMITE_BYTES)), b"")
32
+ except (AttributeError, StopIteration, requests.RequestException):
33
+ trozo = (getattr(response, "text", "") or "")[:LIMITE_BYTES]
34
+ return trozo.decode("utf-8", "replace") if isinstance(trozo, bytes) else str(trozo)
35
+
36
+
37
+ def _titulo(cuerpo: str) -> Optional[str]:
38
+ m = _TITULO.search(cuerpo)
39
+ if not m:
40
+ return None
41
+ texto = " ".join(_html.unescape(m.group(1)).split())
42
+ return texto[:120] or None
43
+
44
+
45
+ def _fallo(url: str, esperada: bool, problema: str) -> PagesHealth:
46
+ return PagesHealth(
47
+ estado=ESTADO_ROTA if esperada else ESTADO_NINGUNA, url=url, problemas=[problema]
48
+ )
49
+
50
+
51
+ def inspeccionar(session, url: str, esperada: bool = False) -> PagesHealth:
52
+ inicio = time.monotonic()
53
+ try:
54
+ response = session.get(url, allow_redirects=True, stream=True)
55
+ except requests.exceptions.SSLError:
56
+ return _fallo(url, esperada, "tls-error")
57
+ except requests.exceptions.Timeout:
58
+ return _fallo(url, esperada, "timeout")
59
+ except requests.exceptions.TooManyRedirects:
60
+ return _fallo(url, esperada, "bucle-de-redirecciones")
61
+ except requests.RequestException:
62
+ return _fallo(url, esperada, "sin-conexion")
63
+
64
+ ms = int((time.monotonic() - inicio) * 1000)
65
+ problemas, avisos = [], []
66
+ titulo = None
67
+ try:
68
+ status = response.status_code
69
+ final = getattr(response, "url", None) or url
70
+ saltos = len(getattr(response, "history", None) or [])
71
+ host_pedido = urlparse(url).netloc.lower()
72
+ host_final = urlparse(final).netloc.lower()
73
+
74
+ if status != 200:
75
+ problemas.append("404" if status == 404 else f"http-{status}")
76
+ else:
77
+ if host_final != host_pedido and not host_pedido.endswith(".github.io"):
78
+ problemas.append("redireccion-externa") # dominio aparcado, etc.
79
+ if saltos > MAX_REDIRECCIONES:
80
+ problemas.append("demasiadas-redirecciones")
81
+ cuerpo = _leer_inicio(response)
82
+ if not cuerpo.strip():
83
+ problemas.append("cuerpo-vacio")
84
+ elif not _HTML.search(cuerpo):
85
+ avisos.append("sin-html")
86
+ titulo = _titulo(cuerpo)
87
+ if ms > UMBRAL_LENTA_MS:
88
+ avisos.append("lenta")
89
+ finally:
90
+ response.close()
91
+
92
+ if not problemas:
93
+ estado = ESTADO_OK
94
+ else:
95
+ estado = ESTADO_ROTA if esperada else ESTADO_NINGUNA
96
+ # URL efectiva: la pedida, o el dominio personalizado al que GitHub redirige.
97
+ url_final = url if host_final == host_pedido else final
98
+ return PagesHealth(
99
+ estado=estado, url=url, url_final=url_final if estado == ESTADO_OK or esperada else None,
100
+ http_status=status, ms=ms, titulo=titulo, problemas=problemas, avisos=avisos,
101
+ )
@@ -0,0 +1,89 @@
1
+ from rich.console import Console
2
+ from rich.panel import Panel
3
+ from rich.table import Table
4
+
5
+ from . import __version__, creditos
6
+ from .models import AuditReport
7
+
8
+
9
+ def renderizar_consola(report: AuditReport, sin_color: bool = False, console: Console = None):
10
+ console = console or Console(no_color=sin_color)
11
+
12
+ console.print(Panel.fit(
13
+ f"[bold cyan]GitHub Profile & Pages Auditor[/bold cyan] [dim]v{__version__}[/dim]\n"
14
+ f"[dim]Usuario: [bold white]{report.usuario}[/bold white] | "
15
+ f"Dominio: [bold yellow]{report.dominio_base}[/bold yellow] | "
16
+ f"Proveedor: [bold green]{report.proveedor}[/bold green][/dim]",
17
+ border_style="cyan",
18
+ ))
19
+
20
+ t_pages = Table(title="🌐 GitHub Pages Activas", style="cyan", header_style="bold magenta")
21
+ t_pages.add_column("Repositorio", style="bold white", width=30)
22
+ t_pages.add_column("Enlace Desplegado", style="blue underline")
23
+ for r in report.con_pages:
24
+ t_pages.add_row(r.nombre, r.url_pages or "")
25
+ console.print(t_pages)
26
+
27
+ t = Table(title="🛡️ Resumen de Auditoría", style="green", header_style="bold yellow")
28
+ t.add_column("Métrica / Categoría", style="bold white")
29
+ t.add_column("Cantidad", justify="right", style="bold cyan")
30
+ t.add_column("Estado", style="bold")
31
+
32
+ def badge(lista, msg="faltantes"):
33
+ return f"[bold red]⚠️ {len(lista)} {msg}[/bold red]" if lista else "[bold green]✅ OK[/bold green]"
34
+
35
+ t.add_row("Total Repositorios", str(report.total_repos), "[bold blue]Analizados[/bold blue]")
36
+ t.add_row("GitHub Pages", str(len(report.con_pages)), "[bold green]Activas[/bold green]")
37
+ t.add_row("Releases Publicadas", str(len(report.con_releases)), "[bold green]Con Descargas[/bold green]")
38
+ t.add_row("Sin Web ni Releases", str(len(report.sin_nada)), badge(report.sin_nada, "inactivos"))
39
+ t.add_row("Sin README", str(len(report.sin_readme)), badge(report.sin_readme))
40
+ t.add_row("Sin Licencia", str(len(report.sin_licencia)), badge(report.sin_licencia))
41
+ t.add_row("Sin .gitignore", str(len(report.sin_gitignore)), badge(report.sin_gitignore))
42
+ t.add_row("Sin Descripción", str(len(report.sin_descripcion)), badge(report.sin_descripcion))
43
+ if report.pages_rotas:
44
+ t.add_row("Pages rotas", str(len(report.pages_rotas)), badge(report.pages_rotas, "rotas"))
45
+ if report.pages_con_avisos:
46
+ t.add_row("Pages con avisos", str(len(report.pages_con_avisos)), badge(report.pages_con_avisos, "avisos"))
47
+ t.add_row("Forks", str(len(report.forks)), "[dim]Externos[/dim]")
48
+ t.add_row("Archivados", str(len(report.archivados)), "[dim]Solo lectura[/dim]")
49
+ if report.con_error:
50
+ t.add_row("No analizados", str(len(report.con_error)), badge(report.con_error, "con error"))
51
+ console.print(t)
52
+
53
+
54
+ def _seccion(titulo, nombres):
55
+ return ["", f"=== {titulo} ===", *([f"- {n}" for n in nombres] or ["Ninguno"])]
56
+
57
+
58
+ def generar_txt(report: AuditReport) -> str:
59
+ lineas = [
60
+ f"=== ENLACES DE GITHUB PAGES PARA: {report.usuario} ===",
61
+ f"Dominio base: {report.dominio_base}",
62
+ f"Proveedor de datos: {report.proveedor}",
63
+ "",
64
+ ]
65
+ lineas += [f"- {r.nombre:<32} -> {r.url_pages}" for r in report.con_pages] or [
66
+ "No se encontraron repositorios con GitHub Pages."]
67
+ lineas += _seccion("REPOSITORIOS CON RELEASES", [r.nombre for r in report.con_releases])
68
+ lineas += _seccion("REPOSITORIOS SIN RELEASES NI GITHUB PAGES", [r.nombre for r in report.sin_nada])
69
+ lineas += _seccion("REPOSITORIOS SIN README", [r.nombre for r in report.sin_readme])
70
+ lineas += _seccion("REPOSITORIOS SIN LICENCIA", [r.nombre for r in report.sin_licencia])
71
+ lineas += _seccion("REPOSITORIOS SIN .GITIGNORE", [r.nombre for r in report.sin_gitignore])
72
+ lineas += _seccion("REPOSITORIOS SIN DESCRIPCIÓN (ABOUT)", [r.nombre for r in report.sin_descripcion])
73
+ lineas += _seccion("REPOSITORIOS QUE SON FORKS", [r.nombre for r in report.forks])
74
+ lineas += _seccion("REPOSITORIOS ARCHIVADOS", [r.nombre for r in report.archivados])
75
+ if report.pages_rotas:
76
+ lineas += _seccion("GITHUB PAGES ROTAS", [
77
+ f"{r.nombre}: {', '.join(r.pages_salud.problemas)}" for r in report.pages_rotas])
78
+ if report.pages_con_avisos:
79
+ lineas += _seccion("GITHUB PAGES CON AVISOS", [
80
+ f"{r.nombre}: {', '.join(r.pages_salud.avisos)}" for r in report.pages_con_avisos])
81
+ if report.con_error:
82
+ lineas += _seccion("REPOSITORIOS NO ANALIZADOS (ERROR DE RED)", [f"{r.nombre}: {r.error}" for r in report.con_error])
83
+ lineas += ["", "-" * 60, f"Generado con github-pages-auditor v{__version__}", creditos(sep="\n")]
84
+ return "\n".join(lineas) + "\n"
85
+
86
+
87
+ def exportar_txt(report: AuditReport, ruta_salida: str):
88
+ with open(ruta_salida, "w", encoding="utf-8") as f:
89
+ f.write(generar_txt(report))
@@ -0,0 +1,40 @@
1
+ """Reglas evaluables para ``--fail-on`` (y para la sección ``findings`` del JSON)."""
2
+ from typing import Callable, Dict, List, Sequence
3
+
4
+ from .models import RepoInfo
5
+
6
+ # id estable (parte del contrato público) -> (descripción, predicado "hay hallazgo")
7
+ REGLAS: Dict[str, tuple] = {
8
+ "missing-readme": ("Repositorio sin README", lambda r: not r.tiene_readme),
9
+ "missing-license": ("Repositorio sin licencia", lambda r: not r.tiene_licencia),
10
+ "missing-gitignore": ("Repositorio sin .gitignore", lambda r: not r.tiene_gitignore),
11
+ "missing-description": ("Repositorio sin descripción (About)", lambda r: r.tiene_descripcion is False),
12
+ "broken-pages": ("GitHub Pages activadas pero rotas", lambda r: r.pages_rota),
13
+ }
14
+ REGLA_ERRORES = "analysis-errors" # repos que no se pudieron analizar (red)
15
+ TODAS = tuple(REGLAS) + (REGLA_ERRORES,)
16
+
17
+
18
+ def parsear_fail_on(texto: str) -> Sequence[str]:
19
+ """'missing-license,broken-pages' | 'any' -> tupla validada de ids."""
20
+ ids = [t.strip().lower() for t in texto.split(",") if t.strip()]
21
+ if "any" in ids:
22
+ return TODAS
23
+ desconocidas = [i for i in ids if i not in TODAS]
24
+ if desconocidas:
25
+ raise ValueError(
26
+ f"reglas desconocidas: {', '.join(desconocidas)}. Válidas: any, {', '.join(TODAS)}"
27
+ )
28
+ return tuple(dict.fromkeys(ids))
29
+
30
+
31
+ def evaluar(repos: List[RepoInfo]) -> Dict[str, List[str]]:
32
+ """Devuelve {regla: [repos con hallazgo]} para TODAS las reglas (listas vacías incluidas)."""
33
+ analizados = [r for r in repos if not r.error]
34
+ resultado = {rid: [r.nombre for r in analizados if pred(r)] for rid, (_, pred) in REGLAS.items()}
35
+ resultado[REGLA_ERRORES] = [r.nombre for r in repos if r.error]
36
+ return resultado
37
+
38
+
39
+ def disparadas(hallazgos: Dict[str, List[str]], fail_on: Sequence[str]) -> List[str]:
40
+ return [rid for rid in fail_on if hallazgos.get(rid)]