gioser

sergio/takana

> Una distribución Linux construida con un laboratorio funcional y hermético en el > sótano, y una terminal mutable, clásica y anárquica en el piso de arriba — diseñada > desde el suelo para que una IA programadora entienda, modifique y co…

TOML 1572Shell 203Rust 109Markdown 993728 Sutichaykuna

⎇ 6735a75d40732ae19a3ba985c583fd8822b15e01 scripts/licencias-fuente.py Python · 567 siq'ikuna · 27.9 KB Ñawpa willay Pi imata llamkarqan ch'uya

licencias: la cola de pendientes también se escribe — «lo que falta y por qué» es dato 058162c6d · Sergio ·

1#!/usr/bin/env python3
2"""licencias-fuente.py — deduce el SPDX de una receta desde SU PROPIA fuente pineada.
3
4── QUÉ AÑADE SOBRE LOS HERMANOS ────────────────────────────────────────────────────────────────
5`licencias.sh --sembrar` escribe desde una tabla curada a mano, y `licencias-desambiguar.sh`
6resuelve `-only` vs `-or-later` preguntándole a la búsqueda de código de GitHub. Los dos dejan
7fuera lo mismo: lo que no está en la tabla y lo que no vive en GitHub.
8
9Pero `work/tarballs/` guarda cada tarball indexado por su **sha256**, o sea que el árbol EXACTO que
10la receta pinea ya está en disco. La evidencia más fuerte posible —la declaración del propio autor
11en el commit que construimos— no necesita red: necesita abrir el tar. Y para las recetas de fuente
12git, el `commit` pineado cumple el mismo papel que el sha256: se lee sin clonar el árbol entero.
13
14── LA JERARQUÍA DE EVIDENCIA, DE MÁS FUERTE A MÁS DÉBIL ────────────────────────────────────────
15  1. `Cargo.toml` / `meson.build` de la raíz. El autor declara el SPDX él mismo, en el formato
16     normativo. No hay nada que interpretar.
17  2. Un único fichero en `LICENSES/` (convención REUSE, que usa todo KDE): el nombre del fichero
18     ES el identificador SPDX.
19  3. El texto de `COPYING`/`LICENSE` + la CONCESIÓN buscada en las cabeceras de los fuentes.
20
21── POR QUÉ EL PASO 3 SON DOS PASOS Y NO UNO ────────────────────────────────────────────────────
22El COPYING de la GPL es IDÉNTICO para `-only` y `-or-later`: es el texto de la licencia, no la
23concesión. Peor: su apéndice «cómo aplicar la licencia» contiene literalmente «or (at your option)
24any later version», así que buscar esa frase en el COPYING da SIEMPRE positivo y parece evidencia
25siendo plantilla. La concesión vive en las cabeceras de los fuentes y en el README — por eso se
26busca ahí y se EXCLUYEN explícitamente COPYING/LICENSE/LICENCE. Es la misma regla que ya fijó
27`licencias-desambiguar.sh`; acá se aplica sobre el árbol pineado en vez de sobre GitHub.
28
29── SE GUARDA LA CITA ───────────────────────────────────────────────────────────────────────────
30Cada veredicto sale con el fichero y la frase que lo decidió. Una licencia es una afirmación legal:
31quien la revise tiene que poder auditarla sin repetir el trabajo. Lo que no alcanza para un
32veredicto sale como `pendiente` con lo que SÍ se encontró, para que el humano arranque desde ahí.
33NADA se escribe en las recetas: la salida es un TSV que revisa una persona y siembra `licencias.sh`.
34
35── EL MODO `--fetch` ───────────────────────────────────────────────────────────────────────────
36Por defecto NO toca la red: mira sólo lo que ya está en `work/tarballs/`. Con `--fetch` trae a un
37temporal lo que falte —tarballs y fuentes git—, verifica el tarball por sha256 —el mismo sha que la receta pinea, así
38que o es el árbol exacto o no se mira— y los borra al terminar. No escribe en la caché de hammer a
39propósito: la caché es suya y un fichero puesto ahí por otro camino es una vía de envenenamiento
40que nadie audita.
41
42Uso:  scripts/licencias-fuente.py            informe + escribe docs/licencias-evidencia.tsv
43      scripts/licencias-fuente.py --fetch    además trae las fuentes que falten (red)
44      scripts/licencias-fuente.py <receta>   una sola, con el detalle a la vista
45"""
46import os
47import re
48import sys
49import glob
50import shutil
51import hashlib
52import tarfile
53import tempfile
54import subprocess
55import tomllib
56
57RAIZ = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
58CACHE = os.path.join(RAIZ, "work", "tarballs")
59SALIDA = os.path.join(RAIZ, "docs", "licencias-evidencia.tsv")
60COLA = os.path.join(RAIZ, "docs", "licencias-pendientes.tsv")
61
62# Cada licencia se reconoce por una frase que no aparece en las demás. El texto se NORMALIZA
63# (espacios colapsados) antes de buscar, porque el mismo texto viene con saltos de línea distintos
64# según quién lo empaquete.
65#
66# ⚠ No se elige «la primera que pegue». Si un fichero contiene VARIAS licencias —el LICENCE de
67# OpenSSH es un compendio de cuatro— cualquier elección sería una afirmación falsa, así que se
68# devuelven todas y decide un humano. Las únicas colapsables son las que se contienen entre sí:
69# el texto de BSD-3 incluye el de BSD-2, y el de 0BSD se parece al de ISC sin su cláusula.
70FAMILIAS = [
71    ("AGPL-3.0", "GNU AFFERO GENERAL PUBLIC LICENSE"),
72    ("LGPL-3.0", "GNU LESSER GENERAL PUBLIC LICENSE Version 3"),
73    ("LGPL-2.1", "GNU LESSER GENERAL PUBLIC LICENSE Version 2.1"),
74    ("LGPL-2.0", "GNU LIBRARY GENERAL PUBLIC LICENSE"),
75    ("GPL-3.0", "GNU GENERAL PUBLIC LICENSE Version 3"),
76    ("GPL-2.0", "GNU GENERAL PUBLIC LICENSE Version 2, June 1991"),
77    ("Apache-2.0", "Apache License Version 2.0"),
78    ("MPL-2.0", "Mozilla Public License Version 2.0"),
79    ("CC0-1.0", "CC0 1.0 Universal"),
80    ("0BSD", "Permission to use, copy, modify, and/or distribute this software for any purpose "
81             "with or without fee is hereby granted. THE SOFTWARE IS PROVIDED"),
82    ("ISC", "Permission to use, copy, modify, and/or distribute this software for any purpose "
83            "with or without fee is hereby granted, provided that the above copyright notice"),
84    ("Zlib", "altered source versions must be plainly marked as such"),
85    ("MIT", "without limitation the rights to use, copy, modify, merge, publish"),
86    ("BSD-4-Clause", "All advertising materials mentioning features or use of this software"),
87    ("BSD-3-Clause", "endorse or promote products derived"),
88    ("BSD-2-Clause", "Redistribution and use in source and binary forms"),
89]
90# `mayor: [las que absorbe]` — un texto que contiene al otro no son dos licencias, es una.
91ABSORBE = {
92    "BSD-4-Clause": ["BSD-3-Clause", "BSD-2-Clause"],
93    "BSD-3-Clause": ["BSD-2-Clause"],
94    "0BSD": ["ISC"],
95    "LGPL-3.0": ["GPL-3.0"],
96    "LGPL-2.1": ["GPL-2.0"],
97    "AGPL-3.0": ["GPL-3.0"],
98}
99# Excepciones que cambian el identificador y son invisibles al texto base de la licencia.
100EXCEPCIONES = [("Apache-2.0", "LLVM Exceptions", "Apache-2.0 WITH LLVM-exception")]
101# Las familias GNU son las únicas donde el TEXTO no dice la concesión: `-only` y `-or-later`
102# comparten COPYING palabra por palabra. Se resuelven aparte, en las cabeceras de los fuentes.
103AMBIGUAS = {"AGPL-3.0", "LGPL-3.0", "LGPL-2.1", "LGPL-2.0", "GPL-3.0", "GPL-2.0"}
104
105NOMBRES_LICENCIA = re.compile(r"^(COPYING|LICEN[CS]E)([.-].*)?$", re.I)
106# Ficheros donde SÍ vive la concesión. Se excluyen los de arriba a propósito (ver cabecera).
107EXT_FUENTE = (
108    ".c", ".h", ".cc", ".cpp", ".cxx", ".hpp", ".py", ".rs", ".go", ".sh",
109    ".am", ".ac", ".build", ".txt", ".md", ".in", ".pl", ".java", ".m4",
110)
111FRASE_LATER = re.compile(
112    r"(?:any|either)\s+later\s+version|(?:GPL|GPLv?[23])\+|version\s+[23](?:\.\d)?\s+or\s+(?:any\s+)?later",
113    re.I,
114)
115FRASE_ONLY = re.compile(
116    r"version\s+[23](?:\.\d)?\s+only|only\s+version\s+[23]|SPDX-License-Identifier:\s*\S*GPL-[23]\.0-only",
117    re.I,
118)
119def spdx_conocidos():
120    """Los identificadores cuyo TEXTO tenemos en `licenses/` (los baja licencias-textos.sh).
121
122    Es la lista blanca correcta y no una regex, por una razón que no es de comodidad: la obligación
123    al distribuir binarios es acompañarlos del TEXTO de la licencia. Un identificador del que no
124    tenemos el texto es un identificador que no podemos cumplir — así que declararlo no adelanta
125    nada y sí crea una afirmación que no se sostiene. Si aparece uno nuevo, el paso siguiente es
126    bajar su texto, no relajar la validación.
127
128    Esto es lo que atrapa el «GPL2+» que meson deja escribir: no es un SPDX, es taquigrafía.
129    """
130    d = os.path.join(RAIZ, "licenses")
131    if not os.path.isdir(d):
132        return set()
133    return {os.path.splitext(f)[0] for f in os.listdir(d) if f.endswith(".txt")}
134
135
136CONOCIDOS = spdx_conocidos()
137PEGAMENTO = {"AND", "OR", "WITH"}
138
139
140def spdx_valido(expr):
141    """Toda pieza de la expresión tiene que ser un identificador del que tengamos el texto."""
142    if not CONOCIDOS:
143        return True          # sin `licenses/` no hay con qué validar; no se inventa un veredicto
144    piezas = [x.strip("()") for x in expr.split() if x.upper() not in PEGAMENTO]
145    return bool(piezas) and all(x in CONOCIDOS for x in piezas)
146
147SPDX_HDR = re.compile(rb"SPDX-License-Identifier:\s*([A-Za-z0-9 .+()-]+)")
148
149# Plantilla de autotools/libtool: viene con el generador, NO la escribió el proyecto, y casi toda
150# es GPL-2.0-or-later. `nano` (GPL-3.0) salía «-or-later» citando el «either version 2» de su
151# `aclocal.m4`: una concesión que el proyecto nunca hizo. La cita tiene que ser del autor.
152BOILERPLATE = re.compile(
153    r"^(m4/|build-aux/|autom4te|aclocal\.m4$|configure$|configure\.ac$|ltmain\.sh$|"
154    r"config\.(guess|sub|rpath)$|install-sh$|missing$|depcomp$|compile$|test-driver$)"
155)
156# «either version 3 of the License» → 3. Sirve para exigir que la concesión hable de la MISMA
157# versión que el COPYING: un veredicto GPL-3.0 justificado con una frase que dice «version 2» es
158# incoherente, y era exactamente el caso de nano.
159VERSION_EN_FRASE = re.compile(r"version\s+([23])(?:\.\d)?|GPL-?v?([23])\+|GPL-([23])\.0", re.I)
160
161MAX_BYTES = 512 * 1024   # un fichero de fuente más grande que esto no es una cabecera de licencia
162MAX_MIEMBROS = 6000      # cota para no recorrer árboles gigantes enteros
163
164
165def recetas_sin_licencia():
166    out = []
167    for f in sorted(glob.glob(os.path.join(RAIZ, "recipes", "*.toml")) +
168                    glob.glob(os.path.join(RAIZ, "recipes", "*", "*.toml"))):
169        try:
170            d = tomllib.load(open(f, "rb"))
171        except Exception:
172            continue
173        if "name" in d and not d.get("license"):
174            out.append((f, d))
175    return out
176
177
178def sin_prefijo(nombre):
179    """`kcompletion-6.27.0/src/x.c` → `src/x.c`. El tar trae un directorio raíz."""
180    return nombre.split("/", 1)[1] if "/" in nombre else ""
181
182
183def una_linea(txt, tope=160):
184    """La cita se recorta a UNA línea: lleva texto de un fuente y sus saltos partían la fila del TSV
185    en varias, que es un fichero corrupto con aspecto de fichero bueno."""
186    t = " ".join(str(txt).split()).replace("\t", " ")
187    return t if len(t) <= tope else t[:tope - 1] + "…"
188
189
190def concuerda(cita, mayor):
191    """¿La cita habla de la misma versión que el COPYING? Si no, no justifica nada."""
192    if not cita:
193        return False
194    m = VERSION_EN_FRASE.search(cita)
195    if not m:
196        return True          # una cabecera SPDX sin número (p. ej. «GPL-2.0-only») ya es explícita
197    return (m.group(1) or m.group(2) or m.group(3)) == mayor
198
199
200def identificar(texto):
201    """Todas las licencias presentes en un texto, ya colapsadas las que se contienen."""
202    # Se quita el prefijo de comentario de cada línea antes de colapsar: el COPYING de libssh2 va
203    # dentro de un bloque `/* … */` y sus `*` partían la frase «endorse or * promote products»,
204    # que es justo la marca de la cláusula 3 ⇒ se declaraba BSD-2 un texto BSD-3.
205    t = " ".join(re.sub(r"^[\s*#/]+", "", l) for l in texto.split("\n"))
206    t = " ".join(t.split())
207    hits = [spdx for spdx, marca in FAMILIAS if " ".join(marca.split()) in t]
208    for mayor, menores in ABSORBE.items():
209        if mayor in hits:
210            hits = [h for h in hits if h not in menores]
211    for base, marca, con_excepcion in EXCEPCIONES:
212        if hits == [base] and marca in t:
213            hits = [con_excepcion]
214    return hits
215
216
217def declaracion_del_autor(ruta, texto):
218    """El SPDX que el propio autor escribe en su fichero de build. Evidencia de primera."""
219    base = os.path.basename(ruta)
220    if base == "Cargo.toml":
221        m = re.search(r'^\s*license\s*=\s*"([^"]+)"', texto, re.M)
222        if m:
223            return m.group(1), f"{ruta}: license = \"{m.group(1)}\""
224    if base == "meson.build":
225        m = re.search(r"license\s*:\s*\[?\s*'([^']+)'", texto)
226        if m:
227            return m.group(1), f"{ruta}: license: '{m.group(1)}'"
228    return None, None
229
230
231def miembros_de_tar(tar_path):
232    """(ruta relativa a la raíz, bytes) de cada fichero del tar. Un solo recorrido, en streaming."""
233    try:
234        tf = tarfile.open(tar_path, "r:*")
235    except Exception as e:
236        raise RuntimeError(f"no se pudo abrir el tar: {e}")
237    with tf:
238        for i, m in enumerate(tf):
239            if i > MAX_MIEMBROS:
240                break
241            if not m.isfile() or m.size > MAX_BYTES:
242                continue
243            rel = sin_prefijo(m.name)
244            if not rel:
245                continue
246            try:
247                yield rel, tf.extractfile(m).read()
248            except Exception:
249                continue
250
251
252def miembros_de_git(repo, commit, tmp):
253    """Igual, pero desde un repo git, SIN clonar el árbol entero.
254
255    `--depth 1 --filter=blob:limit=64k` sobre el commit PINEADO: trae la historia de un solo commit
256    y sólo los blobs chicos. Un COPYING nunca pasa de 64k, y los fuentes donde vive la concesión
257    tampoco — los que sí (binarios, assets) son justo los que no interesan. Así una receta cuyo
258    árbol pesa cientos de megas se resuelve con unos pocos.
259    """
260    ejecutar = lambda *a: subprocess.run(a, cwd=tmp, capture_output=True, text=False)
261    subprocess.run(["git", "init", "-q", tmp], capture_output=True)
262    ejecutar("git", "remote", "add", "origin", repo)
263    r = ejecutar("git", "fetch", "-q", "--depth", "1", "--filter=blob:limit=64k", "origin", commit)
264    if r.returncode != 0:
265        raise RuntimeError("git fetch falló: "
266                           + r.stderr.decode("utf-8", "replace").strip()[:100])
267    r = ejecutar("git", "ls-tree", "-r", "-z", "--long", "FETCH_HEAD")
268    if r.returncode != 0:
269        raise RuntimeError("git ls-tree falló")
270    n = 0
271    for reg in r.stdout.decode("utf-8", "replace").split("\0"):
272        if not reg or n > MAX_MIEMBROS:
273            break
274        meta, _, rel = reg.partition("\t")
275        campos = meta.split()
276        # <modo> <tipo> <sha> <tamaño>
277        if len(campos) < 4 or campos[1] != "blob":
278            continue
279        try:
280            if campos[3] != "-" and int(campos[3]) > MAX_BYTES:
281                continue
282        except ValueError:
283            pass
284        b = ejecutar("git", "cat-file", "blob", campos[2])
285        if b.returncode == 0:
286            n += 1
287            yield rel, b.stdout
288
289
290def analizar(miembros):
291    """Un solo recorrido de la fuente. Devuelve (spdx, cita) o (None, pista)."""
292    autor = None
293    no_reconocido = None
294    reuse = []            # ficheros de LICENSES/ (convención REUSE: el nombre ES el SPDX)
295    candidatos = []       # (prioridad, ruta, texto) de los ficheros de licencia de la RAÍZ
296    familia = None
297    familia_fichero = None
298    varias = None
299    cita_later = None
300    cita_only = None
301    spdx_headers = {}
302
303    try:
304        for rel, datos in miembros:
305            base = os.path.basename(rel)
306
307            if rel.startswith("LICENSES/"):
308                reuse.append(base)
309                continue
310
311            # ⚠ SÓLO la raíz del árbol. `pigz` trae `zopfli/COPYING` (Apache-2.0) y el paquete NO es
312            # Apache: un COPYING de un subdirectorio es la licencia de una PIEZA EMPAQUETADA, no la
313            # del paquete. Tomar el primero que aparezca convierte una dep vendorizada en el
314            # veredicto del contenedor.
315            if "/" in rel:
316                es_licencia_raiz = False
317            else:
318                es_licencia_raiz = NOMBRES_LICENCIA.match(base) is not None
319
320            interesa_autor = rel in ("Cargo.toml", "meson.build")
321            es_licencia = es_licencia_raiz
322            es_fuente = rel.lower().endswith(EXT_FUENTE) or base in ("README", "AUTHORS", "NEWS")
323            if not (interesa_autor or es_licencia or es_fuente):
324                continue
325
326            if interesa_autor and autor is None:
327                lic, cita = declaracion_del_autor(rel, datos.decode("utf-8", "replace"))
328                if lic:
329                    autor = (lic, cita)
330
331            if es_licencia:
332                # `COPYING` a secas gana a `COPYING.OpenSSL`: el sufijado suele ser una EXCEPCIÓN o
333                # una licencia añadida, no la del paquete. socat es GPL-2.0 y su `COPYING.OpenSSL`
334                # es BSD — quedarse con el primero que aparezca lo declaraba BSD.
335                exacto = re.fullmatch(r"(COPYING|LICEN[CS]E)", base, re.I) is not None
336                candidatos.append((0 if exacto else 1, rel, datos.decode("utf-8", "replace")))
337                continue  # NUNCA se busca la concesión dentro del propio COPYING
338
339            if es_fuente and BOILERPLATE.match(rel):
340                es_fuente = False
341
342            if es_fuente:
343                h = SPDX_HDR.search(datos)
344                if h:
345                    ident = h.group(1).decode().strip()
346                    spdx_headers[ident] = spdx_headers.get(ident, 0) + 1
347                texto = datos.decode("utf-8", "replace")
348                if cita_later is None:
349                    mm = FRASE_LATER.search(texto)
350                    if mm:
351                        cita_later = f"{rel}: …{texto[max(0, mm.start()-60):mm.end()+20].strip()}…"
352                if cita_only is None:
353                    mm = FRASE_ONLY.search(texto)
354                    if mm:
355                        cita_only = f"{rel}: …{texto[max(0, mm.start()-60):mm.end()+20].strip()}…"
356
357    except RuntimeError as e:
358        return None, str(e)
359
360    if candidatos:
361        # Se identifica CADA fichero de la raíz y se unen los resultados. Que haya varios no es
362        # ambigüedad por sí solo: el `COPYING` de pcre2 son dos líneas que apuntan a `LICENCE.md`,
363        # y los dos dicen BSD-3. Ambiguo es que digan COSAS DISTINTAS.
364        #
365        # Probé antes con «el `COPYING` a secas gana al sufijado»: arregla socat (cuyo
366        # `COPYING.OpenSSL` es una excepción) y ROMPE gmp, que junto a `COPYING` (GPLv3) trae
367        # `COPYING.LESSERv3` porque la biblioteca es LGPL — el veredicto salía GPL-3.0-or-later, que
368        # es falso. La diferencia entre «el extra nombra una dep» y «el extra nombra otra versión»
369        # es demasiado fina para codificarla sin equivocarse, y equivocarse acá es afirmar algo que
370        # no es. ffmpeg es el caso límite: sus cuatro COPYING.* los desempatan los FLAGS de la
371        # receta (`--enable-gpl`), o sea que la respuesta ni siquiera está en el árbol.
372        vistos = {}
373        for _, ruta, texto in sorted(candidatos):
374            for h in identificar(texto):
375                vistos.setdefault(h, ruta)
376        if len(vistos) == 1:
377            familia, familia_fichero = next(iter(vistos.items()))
378        elif len(vistos) > 1:
379            return None, ("la raíz dice " + str(len(vistos)) + " licencias distintas ("
380                          + ", ".join(f"{k} en {v}" for k, v in sorted(vistos.items()))
381                          + "): la expresión la compone un humano")
382        else:
383            # Hay fichero de licencia y NO se reconoce su texto. Es un caso DISTINTO de «no hay
384            # fichero», y se acciona distinto: uno pide una huella nueva, el otro pide buscar la
385            # licencia en otro sitio. Decir lo mismo de los dos mandaba a mirar donde no era.
386            _, familia_fichero, texto = sorted(candidatos)[0]
387            primera = next((l.strip() for l in texto.splitlines() if l.strip()), "")
388            no_reconocido = f'{familia_fichero} existe pero no reconozco su texto — empieza: "{primera[:70]}"' 
389
390    # 1. Declaración del autor — si es un SPDX de verdad. meson deja escribir cualquier cadena, y
391    # `gsd-schemas`/`libgdm` declaran «GPL2+», que NO es un identificador: sembrarlo sería meter
392    # basura con cara de dato. Se devuelve como pendiente, con lo que el autor puso a la vista.
393    if autor:
394        if spdx_valido(autor[0]):
395            return autor
396        return None, (f"el autor declara «{autor[0]}», que no es un SPDX del que tengamos texto "
397                      f"en licenses/ ({autor[1]})")
398
399    # 2. REUSE con un único fichero de licencia: el nombre es el SPDX.
400    únicos = sorted(set(os.path.splitext(x)[0] for x in reuse))
401    if len(únicos) == 1:
402        return únicos[0], f"LICENSES/{reuse[0]} (único, convención REUSE)"
403
404    # 3. Texto de la licencia + concesión en las cabeceras.
405    if familia:
406        if familia not in AMBIGUAS:
407            return familia, f"{familia_fichero} (texto de {familia})"
408        mayor = familia.rsplit("-", 1)[1][0]         # «GPL-3.0» → «3»
409        if not concuerda(cita_later, mayor):
410            cita_later = None
411        if not concuerda(cita_only, mayor):
412            cita_only = None
413        if cita_later and not cita_only:
414            return f"{familia}-or-later", f"{familia_fichero} + concesión en {cita_later}"
415        if cita_only and not cita_later:
416            return f"{familia}-only", f"{familia_fichero} + concesión en {cita_only}"
417        pista = f"{familia} ambigua"
418        if cita_later and cita_only:
419            pista += " · el árbol dice las DOS cosas, decide un humano"
420        else:
421            pista += " · sin concesión fuera del COPYING"
422        return None, pista
423
424    if varias:
425        return None, (f"{familia_fichero} mezcla {len(varias)} licencias ("
426                      + ", ".join(varias) + "): la expresión la compone un humano")
427    if únicos:
428        return None, "REUSE con varias licencias: " + ",".join(únicos)
429    if no_reconocido:
430        return None, no_reconocido
431    if spdx_headers:
432        top = sorted(spdx_headers.items(), key=lambda x: -x[1])[:3]
433        return None, "sólo cabeceras SPDX: " + ", ".join(f"{k}×{v}" for k, v in top)
434    return None, "sin COPYING ni declaración en el árbol"
435
436
437def bajar_verificado(url, sha, destino):
438    """Baja `url` a `destino` y confirma su sha256. Devuelve el porqué si no se pudo."""
439    r = subprocess.run(
440        ["curl", "-fsSL", "--max-time", "180", "-o", destino, url],
441        capture_output=True, text=True,
442    )
443    if r.returncode != 0:
444        return f"curl falló ({r.returncode}): {r.stderr.strip()[:80]}"
445    h = hashlib.sha256()
446    with open(destino, "rb") as fh:
447        for bloque in iter(lambda: fh.read(1 << 20), b""):
448            h.update(bloque)
449    if h.hexdigest() != sha:
450        return f"sha256 NO coincide: bajó {h.hexdigest()[:16]}…, la receta pinea {sha[:16]}…"
451    return None
452
453
454def main():
455    args = [a for a in sys.argv[1:]]
456    con_red = "--fetch" in args
457    args = [a for a in args if a != "--fetch"]
458    objetivo = args[0] if args else None
459    tmp = tempfile.mkdtemp(prefix="licencias-") if con_red else None
460    filas, pendientes, sin_tar = [], [], []
461
462    for f, d in recetas_sin_licencia():
463        nombre = d["name"]
464        if objetivo and nombre != objetivo and os.path.basename(f) != objetivo:
465            continue
466        src = d.get("source", {})
467        sha = src.get("sha256")
468        if not sha:
469            # ── Fuente git: el commit pineado ES la evidencia, igual que un sha256 ──────────────
470            repo, commit = src.get("repo"), src.get("commit")
471            if not (repo and commit):
472                sin_tar.append((nombre, "fuente sin tarball ni repo/commit"))
473                continue
474            if repo.startswith(("ssh://", "git@")):
475                # El fetch de este script es anónimo; un repo por SSH necesita claves que no tiene.
476                sin_tar.append((nombre, "repo por SSH: este script no autentica"))
477                continue
478            if not con_red:
479                sin_tar.append((nombre, "fuente git (probá --fetch)"))
480                continue
481            sub = os.path.join(tmp, nombre + "-git")
482            os.makedirs(sub, exist_ok=True)
483            try:
484                spdx, cita = analizar(miembros_de_git(repo, commit, sub))
485            except RuntimeError as e:
486                sin_tar.append((nombre, str(e)))
487                continue
488        else:
489            tar_path = os.path.join(CACHE, sha + ".tar")
490            if not os.path.exists(tar_path):
491                url = src.get("tarball")
492                if not (con_red and url):
493                    sin_tar.append((nombre, "tarball no cacheado"
494                                    + ("" if con_red else " (probá --fetch)")))
495                    continue
496                tar_path = os.path.join(tmp, sha)
497                fallo = bajar_verificado(url, sha, tar_path)
498                if fallo:
499                    sin_tar.append((nombre, fallo))
500                    continue
501            spdx, cita = analizar(miembros_de_tar(tar_path))
502        if spdx:
503            filas.append((nombre, spdx, una_linea(cita)))
504        else:
505            pendientes.append((nombre, una_linea(cita)))
506
507    if tmp:
508        shutil.rmtree(tmp, ignore_errors=True)
509
510    if objetivo:
511        for n, s, c in filas:
512            print(f"{n}\t{s}\n   evidencia: {c}")
513        for n, c in pendientes + sin_tar:
514            print(f"{n}\tPENDIENTE — {una_linea(c)}")
515        return
516
517    # ACUMULATIVO, no regenerado: una receta ya sembrada sale del conjunto de entrada (porque ya
518    # tiene `license`), así que reescribir el fichero entero borraría su cita. Y la cita es el
519    # rastro de auditoría — lo único que deja revisar un veredicto sin repetir el trabajo. Un
520    # fichero de evidencia que se olvida de lo que ya probó no es evidencia.
521    previas = {}
522    if os.path.exists(SALIDA):
523        for linea in open(SALIDA):
524            if linea.startswith("#") or not linea.strip():
525                continue
526            partes = linea.rstrip("\n").split("\t")
527            if len(partes) == 3:
528                previas[partes[0]] = tuple(partes)
529    for n, sp, c in filas:
530        previas[n] = (n, sp, c)
531
532    with open(SALIDA, "w") as fh:
533        fh.write("# licencias-evidencia.tsv — lo generó scripts/licencias-fuente.py desde el\n")
534        fh.write("# TARBALL PINEADO de cada receta. Tres columnas: paquete, SPDX, y la CITA que lo\n")
535        fh.write("# decidió. REVISAR A MANO antes de sembrar: es evidencia, no un veredicto firmado.\n")
536        fh.write("# Se ACUMULA: las filas de lo ya sembrado se conservan como rastro de auditoría.\n")
537        for fila in sorted(previas.values()):
538            fh.write("\t".join(fila) + "\n")
539
540    print(f"==> resueltas con evidencia de la fuente: {len(filas)}")
541    for n, s, c in filas:
542        print(f"   {n:28s} {s:34s} {c[:70]}")
543    print(f"\n==> pendientes (hay tarball pero no alcanza para afirmar): {len(pendientes)}")
544    for n, c in pendientes:
545        print(f"   {n:28s} {c}")
546    print(f"\n==> sin tarball que mirar: {len(sin_tar)}")
547    for n, c in sin_tar:
548        print(f"   {n:28s} {c}")
549    # La cola de lo que NO se pudo afirmar también se escribe. Vivía sólo en la salida del script,
550    # o sea que se perdía al cerrar la terminal: para el humano que tiene que decidir, «lo que falta
551    # y por qué» es tan dato como los veredictos. Ésta SÍ se regenera entera en cada corrida —al
552    # revés que la de evidencia, que acumula— porque es una foto de lo que queda, no un registro de
553    # lo que se probó: una pendiente resuelta tiene que DESAPARECER de acá.
554    with open(COLA, "w") as fh:
555        fh.write("# licencias-pendientes.tsv — lo que NO alcanza para afirmar una licencia, y por qué.\n")
556        fh.write("# Lo regenera entero scripts/licencias-fuente.py en cada corrida: es la foto de lo\n")
557        fh.write("# que queda, no un registro. Los veredictos van en licencias-evidencia.tsv.\n")
558        fh.write("#\n# paquete<TAB>por qué no se puede afirmar\n")
559        for n, c in sorted(set(pendientes)) + sorted(set(sin_tar)):
560            fh.write(f"{n}\t{una_linea(c)}\n")
561
562    print(f"\n==> escrito {os.path.relpath(SALIDA, RAIZ)} y {os.path.relpath(COLA, RAIZ)}"
563          f" · NADA se tocó en recipes/")
564
565
566if __name__ == "__main__":
567    main()