tawasuyu

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

⎇ 9e6e3d5aa6795a9323b66d3f6b12c39f3e0d45e2 scripts/licencias-tarball.py Python · 479 siq'ikuna · 23.2 KB Ñawpa willay Pi imata llamkarqan ch'uya

licencias: 96% (1124/1164) — `--fetch` para los tarballs que no estaban en caché 96855001c · Sergio ·

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