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…
⎇ 96855001c6e9276014d68fc9f9f85ec765673e28
scripts/licencias-tarball.py
Ñawpa willay
Pi imata llamkarqan
ch'uya
licencias: 96% (1124/1164) — `--fetch` para los tarballs que no estaban en caché
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()