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 815Rust 56Shell 37Markdown 291229 Definitions

licencias: 95% (1114/1164) — la evidencia sale del tarball pineado, offline

`licencias.sh --sembrar` escribe desde una tabla curada a mano y `licencias-desambiguar.sh` resuelve
`-only` vs `-or-later` preguntándole a la búsqueda de código de GitHub. Los dos dejan fuera lo mismo:
lo que nadie curó y lo que no vive en GitHub.

Pero `work/tarballs/` indexa cada tarball por su sha256, así que el árbol EXACTO que la receta pinea
ya está en disco: la declaración del propio autor, en el commit que construimos, sin red. De las 76
sin licencia, 55 tenían su tarball cacheado y 25 salieron con evidencia citable.

`scripts/licencias-tarball.py` la busca en tres niveles: la declaración del autor (`Cargo.toml`,
`meson.build`), un único fichero en `LICENSES/` (REUSE, que usa KDE), y el texto del COPYING más la
CONCESIÓN buscada en las cabeceras de los fuentes — excluyendo COPYING/LICENSE, porque el apéndice
de la GPL trae literalmente «or (at your option) any later version» y buscarla ahí da siempre
positivo siendo plantilla. Es la regla que ya fijó `licencias-desambiguar.sh`, aplicada al árbol
pineado en vez de a GitHub.

La primera versión resolvía 40, y CUATRO estaban mal. Las dejo escritas porque son la forma del
error, no accidentes:

  · `socat` salía BSD-2 por su `COPYING.OpenSSL`, que es la excepción, no la licencia. Un `COPYING`
    a secas gana ahora a cualquier sufijado.
  · `pigz` salía Apache-2.0 por `zopfli/COPYING`: la licencia de una pieza VENDORIZADA leída como la
    del contenedor. Sólo se mira la raíz.
  · `openssh` salía MIT porque su `LICENCE` es un compendio de cuatro y me quedaba con la primera
    que pegara. Si el texto trae varias, no hay veredicto: lo compone un humano.
  · `nano` salía GPL-3.0-**or-later** citando el «either version 2» de su `aclocal.m4` — plantilla de
    autotools, no del proyecto, y encima de otra versión. Ahora la cita tiene que venir de un fichero
    del autor Y hablar de la misma versión mayor que el COPYING.

Y dos bugs míos que producían el mismo daño en silencio: la marca de BSD-3 era «Neither the name of»,
que libpcap y libzip no usan («The names of the authors may not be used to endorse») ⇒ se declaraban
BSD-2; y el mayor de versión lo sacaba de `"GPL-3.0".rsplit("-",1)[0][-1]`, que da «L», así que la
comprobación de coherencia rechazaba TODA cita válida.

La validación final no es una regex: un identificador vale si tenemos su texto en `licenses/`. La
obligación legal es acompañar el binario del TEXTO, así que un SPDX que no podemos entregar no
adelanta nada y sí crea una afirmación que no se sostiene. Eso es lo que atrapa el «GPL2+» que meson
deja escribir en `gsd-schemas` y `libgdm`, que no es un SPDX sino taquigrafía.

Nada se sembró a ojo: cada línea de `docs/licencias-evidencia.tsv` lleva la cita que la decidió, y
las 50 que quedan salen listadas con lo que SÍ se encontró en vez de rellenarse.

Comprobado que no se invalida nada: los 26 ArtifactHash afectados son idénticos antes y después
(`license` no está en `hash_inputs`) — medido, no supuesto.

ba1fb2d3a8ada748984dceeb0f58e6b3e67772b8 · by Sergio · (1 month ago) · parent 33f7c3731 · browse the tree at this commit

Semantic delta · 10

scripts/licencias-tarball.py

28 files · +465 −0

diff --git a/docs/licencias-evidencia.tsv b/docs/licencias-evidencia.tsv
new file mode 100644
index 00000000..8824b860
--- /dev/null
+++ b/docs/licencias-evidencia.tsv
@@ -0,0 +1,29 @@
+# licencias-evidencia.tsv — lo generó scripts/licencias-tarball.py desde el
+# TARBALL PINEADO de cada receta. Tres columnas: paquete, SPDX, y la CITA que lo
+# decidió. REVISAR A MANO antes de sembrar: es evidencia, no un veredicto firmado.
+adwaita-hello	LGPL-2.1-or-later	meson.build: license: 'LGPL-2.1-or-later'
+dwarves	GPL-2.0-only	COPYING + concesión en btf_encoder.c: …/* SPDX-License-Identifier: GPL-2.0-only Copyright (C) 20…
+elfutils-libdw	GPL-3.0-or-later	COPYING + concesión en tests/run-eu-search-die.sh: …ion; either version 3 of the License, or # (at your option) any later version. # # elfutils is di…
+elfutils	GPL-3.0-or-later	COPYING + concesión en tests/run-eu-search-die.sh: …ion; either version 3 of the License, or # (at your option) any later version. # # elfutils is di…
+file	BSD-2-Clause	COPYING (texto de BSD-2-Clause)
+go	BSD-3-Clause	LICENSE (texto de BSD-3-Clause)
+gtk4-hello	LGPL-2.1-or-later	meson.build: license: 'LGPL-2.1-or-later'
+hammer-edit	LGPL-2.1-or-later	meson.build: license: 'LGPL-2.1-or-later'
+dav1d	BSD-2-Clause	COPYING (texto de BSD-2-Clause)
+exiv2	GPL-2.0-or-later	COPYING + concesión en README.md: …ation; either version 2 of the License, or (at your option) any later version. Exiv2 program is…
+intltool	GPL-2.0-or-later	COPYING + concesión en intltool-extract.in: …on; either version 2 of the # License, or (at your option) any later version. # # Intltool is d…
+libpcap	BSD-3-Clause	LICENSE (texto de BSD-3-Clause)
+libzip	BSD-3-Clause	LICENSE (texto de BSD-3-Clause)
+mtdev	MIT	COPYING (texto de MIT)
+json-c	MIT	COPYING (texto de MIT)
+libsodium	ISC	LICENSE (texto de ISC)
+libssh2	BSD-3-Clause	COPYING (texto de BSD-3-Clause)
+mesa-llvmpipe	MIT	meson.build: license: 'MIT'
+mesa-swrast	MIT	meson.build: license: 'MIT'
+mtdev	MIT	COPYING (texto de MIT)
+nano	GPL-3.0-or-later	COPYING + concesión en NEWS: …Finally, nano is now licensed under the GNU GPL version 3 or later, and its documentat…
+scdoc	MIT	COPYING (texto de MIT)
+seatd	MIT	meson.build: license: 'MIT'
+socat	GPL-2.0-or-later	COPYING + concesión en socat_buildscript_for_android.sh: …; either version 2.1 of the License, or (at your option) any later version. The GNU C Libr…
+sourceview-hello	LGPL-2.1-or-later	meson.build: license: 'LGPL-2.1-or-later'
+tllist	MIT	meson.build: license: 'MIT'
diff --git a/recipes/adwaita-hello.toml b/recipes/adwaita-hello.toml
index 04b69ed5..86f6a696 100644
--- a/recipes/adwaita-hello.toml
+++ b/recipes/adwaita-hello.toml
@@ -3,6 +3,7 @@
 # libadwaita-1 + las privadas de gdk/gsk/appstream que el .pc no declara. Mismo patrón que gtk4-hello.
 name    = "adwaita-hello"
 version = "1.7.6"
+license = "LGPL-2.1-or-later"
 
 [source]
 tarball = "https://download.gnome.org/sources/libadwaita/1.7/libadwaita-1.7.6.tar.xz"
diff --git a/recipes/dwarves.toml b/recipes/dwarves.toml
index a97730d7..fa6fc5fe 100644
--- a/recipes/dwarves.toml
+++ b/recipes/dwarves.toml
@@ -59,6 +59,7 @@
 #
 name    = "dwarves"
 version = "1.30"
+license = "GPL-2.0-only"
 
 [source]
 tarball = "https://fedorapeople.org/~acme/dwarves/dwarves-1.30.tar.xz"
diff --git a/recipes/elfutils-libdw.toml b/recipes/elfutils-libdw.toml
index 6dbecded..389d553b 100644
--- a/recipes/elfutils-libdw.toml
+++ b/recipes/elfutils-libdw.toml
@@ -44,6 +44,7 @@
 #
 name    = "elfutils-libdw"
 version = "0.194"
+license = "GPL-3.0-or-later"
 
 [source]
 tarball = "https://sourceware.org/elfutils/ftp/0.194/elfutils-0.194.tar.bz2"
diff --git a/recipes/elfutils.toml b/recipes/elfutils.toml
index 539f2254..d9fb5905 100644
--- a/recipes/elfutils.toml
+++ b/recipes/elfutils.toml
@@ -20,6 +20,7 @@
 
 name    = "elfutils"
 version = "0.194"
+license = "GPL-3.0-or-later"
 
 [source]
 tarball = "https://sourceware.org/elfutils/ftp/0.194/elfutils-0.194.tar.bz2"
diff --git a/recipes/file.toml b/recipes/file.toml
index e638ea49..1d019af7 100644
--- a/recipes/file.toml
+++ b/recipes/file.toml
@@ -3,6 +3,7 @@
 # sha256 del tarball (el wrapper lo calcula), y adaptar build/install del shell de abuild.
 name    = "file"
 version = "5.47"
+license = "BSD-2-Clause"
 
 [source]
 tarball = "https://astron.com/pub/file/file-5.47.tar.gz"
diff --git a/recipes/go.toml b/recipes/go.toml
index 23bfde49..b0e7a532 100644
--- a/recipes/go.toml
+++ b/recipes/go.toml
@@ -9,6 +9,7 @@
 # frente futuro, análogo a mrustc para Rust.
 name    = "go"
 version = "1.26.4"
+license = "BSD-3-Clause"
 
 [source]
 tarball = "https://go.dev/dl/go1.26.4.linux-amd64.tar.gz"
diff --git a/recipes/gtk4-hello.toml b/recipes/gtk4-hello.toml
index 017e29a5..4aef2cc5 100644
--- a/recipes/gtk4-hello.toml
+++ b/recipes/gtk4-hello.toml
@@ -4,6 +4,7 @@
 # de GUI del lab es usable por apps, no solo "compila". deps = cierre .pc completo + gtk4.
 name    = "gtk4-hello"
 version = "4.18.6"
+license = "LGPL-2.1-or-later"
 
 [source]
 tarball = "https://download.gnome.org/sources/gtk/4.18/gtk-4.18.6.tar.xz"
diff --git a/recipes/hammer-edit.toml b/recipes/hammer-edit.toml
index 2bf5f9c4..086d3588 100644
--- a/recipes/hammer-edit.toml
+++ b/recipes/hammer-edit.toml
@@ -3,6 +3,7 @@
 # estático (gtk4+libadwaita+gtksourceview). No es una demo: es una app instalable y usable.
 name    = "hammer-edit"
 version = "1.0.0"
+license = "LGPL-2.1-or-later"
 
 [source]
 tarball = "https://download.gnome.org/sources/gtksourceview/5.16/gtksourceview-5.16.0.tar.xz"
diff --git a/recipes/incoming-cosmic/dav1d.toml b/recipes/incoming-cosmic/dav1d.toml
index 8aabddbc..4f94535f 100644
--- a/recipes/incoming-cosmic/dav1d.toml
+++ b/recipes/incoming-cosmic/dav1d.toml
@@ -21,6 +21,7 @@
 #   etiqueta falsa — la deuda que el frente static-audit ya persiguió una vez.
 name    = "dav1d"
 version = "1.5.4"
+license = "BSD-2-Clause"
 
 [source]
 tarball = "https://code.videolan.org/videolan/dav1d/-/archive/1.5.4/dav1d-1.5.4.tar.gz"
diff --git a/recipes/incoming-kde/exiv2.toml b/recipes/incoming-kde/exiv2.toml
index 9ed90425..d26ad431 100644
--- a/recipes/incoming-kde/exiv2.toml
+++ b/recipes/incoming-kde/exiv2.toml
@@ -5,6 +5,7 @@
 # runtime = libz.so (zlib-shared). Sin CLI ni samples.
 name    = "exiv2"
 version = "0.28.5"
+license = "GPL-2.0-or-later"
 
 [source]
 tarball = "https://github.com/Exiv2/exiv2/archive/refs/tags/v0.28.5.tar.gz"
diff --git a/recipes/incoming-kde/intltool.toml b/recipes/incoming-kde/intltool.toml
index bd7fd4c1..0f42cc63 100644
--- a/recipes/incoming-kde/intltool.toml
+++ b/recipes/incoming-kde/intltool.toml
@@ -3,6 +3,7 @@
 # Autotools. Parche conocido: intltool 0.51 usa `\${\` en regex Perl que perl 5.26+ rechaza ⇒ sed fix.
 name    = "intltool"
 version = "0.51.0"
+license = "GPL-2.0-or-later"
 
 [source]
 tarball = "https://launchpad.net/intltool/trunk/0.51.0/+download/intltool-0.51.0.tar.gz"
diff --git a/recipes/incoming-kde/libpcap.toml b/recipes/incoming-kde/libpcap.toml
index b7205c27..474d546b 100644
--- a/recipes/incoming-kde/libpcap.toml
+++ b/recipes/incoming-kde/libpcap.toml
@@ -2,6 +2,7 @@
 # flex/bison para el parser de filtros BPF. Backend PF_PACKET en Linux (sin libnl para minimizar).
 name    = "libpcap"
 version = "1.10.5"
+license = "BSD-3-Clause"
 [source]
 tarball = "https://www.tcpdump.org/release/libpcap-1.10.5.tar.xz"
 sha256  = "84fa89ac6d303028c1c5b754abff77224f45eca0a94eb1a34ff0aa9ceece3925"
diff --git a/recipes/incoming-kde/libzip.toml b/recipes/incoming-kde/libzip.toml
index 3bfd4a8c..0212e16c 100644
--- a/recipes/incoming-kde/libzip.toml
+++ b/recipes/incoming-kde/libzip.toml
@@ -5,6 +5,7 @@
 # como .so dinámicos (bzip2-shared/xz-shared aportan el PIC); openssl para el cifrado AES del ZIP.
 name    = "libzip"
 version = "1.11.3"
+license = "BSD-3-Clause"
 
 [source]
 tarball = "https://libzip.org/download/libzip-1.11.3.tar.xz"
diff --git a/recipes/incoming-kde/mtdev.toml b/recipes/incoming-kde/mtdev.toml
index dbd98b9c..3ffcc130 100644
--- a/recipes/incoming-kde/mtdev.toml
+++ b/recipes/incoming-kde/mtdev.toml
@@ -4,6 +4,7 @@
 # Dep de build de libinput (que kwin exige ≥1.28). Autotools.
 name    = "mtdev"
 version = "1.1.6"
+license = "MIT"
 
 [source]
 tarball = "https://bitmath.org/code/mtdev/mtdev-1.1.6.tar.gz"
diff --git a/recipes/json-c.toml b/recipes/json-c.toml
index e0ae6586..f5312e14 100644
--- a/recipes/json-c.toml
+++ b/recipes/json-c.toml
@@ -1,6 +1,7 @@
 # json-c 0.18 — lib C base (de-Alpinizada, Etapa G). compiler=zig-cc (migrado de gcc, matar-gcc 2026-07-16), configure-split, estático.
 name    = "json-c"
 version = "0.18"
+license = "MIT"
 
 [source]
 tarball = "https://s3.amazonaws.com/json-c_releases/releases/json-c-0.18.tar.gz"
diff --git a/recipes/libsodium.toml b/recipes/libsodium.toml
index 6275aa1e..9d04f1eb 100644
--- a/recipes/libsodium.toml
+++ b/recipes/libsodium.toml
@@ -2,6 +2,7 @@
 # De-Alpinizada: compiler=gcc, configure-split, estático.
 name    = "libsodium"
 version = "1.0.22"
+license = "ISC"
 [source]
 tarball = "https://download.libsodium.org/libsodium/releases/libsodium-1.0.22.tar.gz"
 sha256  = "adbdd8f16149e81ac6078a03aca6fc03b592b89ef7b5ed83841c086191be3349"
diff --git a/recipes/libssh2.toml b/recipes/libssh2.toml
index 77339f42..8b64cba0 100644
--- a/recipes/libssh2.toml
+++ b/recipes/libssh2.toml
@@ -2,6 +2,7 @@
 # De-Alpinizada: compiler=zig-cc (migrado de gcc, matar-gcc 2026-07-16), configure-split, crypto vía openssl del corpus, estático.
 name    = "libssh2"
 version = "1.11.1"
+license = "BSD-3-Clause"
 [source]
 tarball = "https://www.libssh2.org/download/libssh2-1.11.1.tar.gz"
 sha256  = "d9ec76cbe34db98eec3539fe2c899d26b0c837cb3eb466a56b0f109cabf658f7"
diff --git a/recipes/mesa-llvmpipe.toml b/recipes/mesa-llvmpipe.toml
index 2de3e558..90cfe9fa 100644
--- a/recipes/mesa-llvmpipe.toml
+++ b/recipes/mesa-llvmpipe.toml
@@ -8,6 +8,7 @@
 # desde fuente primero (el monstruo). Test primero, decidir después.
 name    = "mesa-llvmpipe"
 version = "24.0.9"
+license = "MIT"
 
 [source]
 tarball = "https://archive.mesa3d.org/mesa-24.0.9.tar.xz"
diff --git a/recipes/mesa-swrast.toml b/recipes/mesa-swrast.toml
index f3b4061c..e7033395 100644
--- a/recipes/mesa-swrast.toml
+++ b/recipes/mesa-swrast.toml
@@ -19,6 +19,7 @@
 # TODO idéntico a mesa.toml; ver su encabezado para el detalle de cada uno.
 name    = "mesa-swrast"
 version = "24.0.9"
+license = "MIT"
 
 [source]
 tarball = "https://archive.mesa3d.org/mesa-24.0.9.tar.xz"
diff --git a/recipes/mtdev.toml b/recipes/mtdev.toml
index c5b6eb55..e3c095c2 100644
--- a/recipes/mtdev.toml
+++ b/recipes/mtdev.toml
@@ -1,6 +1,7 @@
 # mtdev 1.1.6 — protocolo multitouch (slots). Dep de build de libinput. Autotools, sin deps.
 name    = "mtdev"
 version = "1.1.6"
+license = "MIT"
 
 [source]
 tarball = "https://bitmath.org/code/mtdev/mtdev-1.1.6.tar.gz"
diff --git a/recipes/nano.toml b/recipes/nano.toml
index 014de33f..69592228 100644
--- a/recipes/nano.toml
+++ b/recipes/nano.toml
@@ -12,6 +12,7 @@
 
 name    = "nano"
 version = "9.0"
+license = "GPL-3.0-or-later"
 
 [source]
 tarball = "https://www.nano-editor.org/dist/v9/nano-9.0.tar.xz"
diff --git a/recipes/scdoc.toml b/recipes/scdoc.toml
index 5737bafd..b7d16b4d 100644
--- a/recipes/scdoc.toml
+++ b/recipes/scdoc.toml
@@ -3,6 +3,7 @@
 # sha256 del tarball (el wrapper lo calcula), y adaptar build/install del shell de abuild.
 name    = "scdoc"
 version = "1.11.5"
+license = "MIT"
 
 [source]
 tarball = "https://git.sr.ht/~sircmpwn/scdoc/archive/1.11.5.tar.gz"
diff --git a/recipes/seatd.toml b/recipes/seatd.toml
index 33bb520f..2d3d3c68 100644
--- a/recipes/seatd.toml
+++ b/recipes/seatd.toml
@@ -3,6 +3,7 @@
 # queremos en el rootfs arje). Sin man-pages (evita scdoc) ni ejemplos. zig 0.13.0 (patrón musl).
 name    = "seatd"
 version = "0.9.3"
+license = "MIT"
 
 [source]
 tarball = "https://git.sr.ht/~kennylevinsen/seatd/archive/0.9.3.tar.gz"
diff --git a/recipes/socat.toml b/recipes/socat.toml
index a10c44fd..5a1c7a10 100644
--- a/recipes/socat.toml
+++ b/recipes/socat.toml
@@ -1,6 +1,7 @@
 # socat 1.8.1.2 — relay de sockets (C, autotools). Deps openssl+readline. De-Alpinizada: gcc, configure-split.
 name    = "socat"
 version = "1.8.1.2"
+license = "GPL-2.0-or-later"
 [source]
 tarball = "http://www.dest-unreach.org/socat/download/socat-1.8.1.2.tar.gz"
 sha256  = "daeb9eed37a99424cd14877208706e93745c91cb86fb917a355635f4df5c8499"
diff --git a/recipes/sourceview-hello.toml b/recipes/sourceview-hello.toml
index d89cdd92..9275d004 100644
--- a/recipes/sourceview-hello.toml
+++ b/recipes/sourceview-hello.toml
@@ -3,6 +3,7 @@
 # esqueleto de un editor de código real para el distro.
 name    = "sourceview-hello"
 version = "5.16.0"
+license = "LGPL-2.1-or-later"
 
 [source]
 tarball = "https://download.gnome.org/sources/gtksourceview/5.16/gtksourceview-5.16.0.tar.xz"
diff --git a/recipes/tllist.toml b/recipes/tllist.toml
index 9af73aad..af017342 100644
--- a/recipes/tllist.toml
+++ b/recipes/tllist.toml
@@ -2,6 +2,7 @@
 # dep de fcft/foot. Sólo instala tllist.h + tllist.pc. meson, sin binario.
 name    = "tllist"
 version = "1.1.0"
+license = "MIT"
 
 [source]
 tarball = "https://codeberg.org/dnkl/tllist/archive/1.1.0.tar.gz"
diff --git a/scripts/licencias-tarball.py b/scripts/licencias-tarball.py
new file mode 100755
index 00000000..9b3f440a
--- /dev/null
+++ b/scripts/licencias-tarball.py
@@ -0,0 +1,410 @@
+#!/usr/bin/env python3
+"""licencias-tarball.py — deduce el SPDX de una receta desde SU PROPIO tarball pineado, offline.
+
+── QUÉ AÑADE SOBRE LOS HERMANOS ────────────────────────────────────────────────────────────────
+`licencias.sh --sembrar` escribe desde una tabla curada a mano, y `licencias-desambiguar.sh`
+resuelve `-only` vs `-or-later` preguntándole a la búsqueda de código de GitHub. Los dos dejan
+fuera lo mismo: lo que no está en la tabla y lo que no vive en GitHub.
+
+Pero `work/tarballs/` guarda cada tarball indexado por su **sha256**, o sea que el árbol EXACTO que
+la receta pinea ya está en disco. La evidencia más fuerte posible —la declaración del propio autor
+en el commit que construimos— no necesita red: necesita abrir el tar.
+
+── LA JERARQUÍA DE EVIDENCIA, DE MÁS FUERTE A MÁS DÉBIL ────────────────────────────────────────
+  1. `Cargo.toml` / `meson.build` de la raíz. El autor declara el SPDX él mismo, en el formato
+     normativo. No hay nada que interpretar.
+  2. Un único fichero en `LICENSES/` (convención REUSE, que usa todo KDE): el nombre del fichero
+     ES el identificador SPDX.
+  3. El texto de `COPYING`/`LICENSE` + la CONCESIÓN buscada en las cabeceras de los fuentes.
+
+── POR QUÉ EL PASO 3 SON DOS PASOS Y NO UNO ────────────────────────────────────────────────────
+El COPYING de la GPL es IDÉNTICO para `-only` y `-or-later`: es el texto de la licencia, no la
+concesión. Peor: su apéndice «cómo aplicar la licencia» contiene literalmente «or (at your option)
+any later version», así que buscar esa frase en el COPYING da SIEMPRE positivo y parece evidencia
+siendo plantilla. La concesión vive en las cabeceras de los fuentes y en el README — por eso se
+busca ahí y se EXCLUYEN explícitamente COPYING/LICENSE/LICENCE. Es la misma regla que ya fijó
+`licencias-desambiguar.sh`; acá se aplica sobre el árbol pineado en vez de sobre GitHub.
+
+── SE GUARDA LA CITA ───────────────────────────────────────────────────────────────────────────
+Cada veredicto sale con el fichero y la frase que lo decidió. Una licencia es una afirmación legal:
+quien la revise tiene que poder auditarla sin repetir el trabajo. Lo que no alcanza para un
+veredicto sale como `pendiente` con lo que SÍ se encontró, para que el humano arranque desde ahí.
+NADA se escribe en las recetas: la salida es un TSV que revisa una persona y siembra `licencias.sh`.
+
+Uso:  scripts/licencias-tarball.py            informe + escribe docs/licencias-evidencia.tsv
+      scripts/licencias-tarball.py <receta>   una sola, con el detalle a la vista
+"""
+import os
+import re
+import sys
+import glob
+import tarfile
+import tomllib
+
+RAIZ = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
+CACHE = os.path.join(RAIZ, "work", "tarballs")
+SALIDA = os.path.join(RAIZ, "docs", "licencias-evidencia.tsv")
+
+# Cada licencia se reconoce por una frase que no aparece en las demás. El texto se NORMALIZA
+# (espacios colapsados) antes de buscar, porque el mismo texto viene con saltos de línea distintos
+# según quién lo empaquete.
+#
+# ⚠ No se elige «la primera que pegue». Si un fichero contiene VARIAS licencias —el LICENCE de
+# OpenSSH es un compendio de cuatro— cualquier elección sería una afirmación falsa, así que se
+# devuelven todas y decide un humano. Las únicas colapsables son las que se contienen entre sí:
+# el texto de BSD-3 incluye el de BSD-2, y el de 0BSD se parece al de ISC sin su cláusula.
+FAMILIAS = [
+    ("AGPL-3.0", "GNU AFFERO GENERAL PUBLIC LICENSE"),
+    ("LGPL-3.0", "GNU LESSER GENERAL PUBLIC LICENSE Version 3"),
+    ("LGPL-2.1", "GNU LESSER GENERAL PUBLIC LICENSE Version 2.1"),
+    ("LGPL-2.0", "GNU LIBRARY GENERAL PUBLIC LICENSE"),
+    ("GPL-3.0", "GNU GENERAL PUBLIC LICENSE Version 3"),
+    ("GPL-2.0", "GNU GENERAL PUBLIC LICENSE Version 2, June 1991"),
+    ("Apache-2.0", "Apache License Version 2.0"),
+    ("MPL-2.0", "Mozilla Public License Version 2.0"),
+    ("CC0-1.0", "CC0 1.0 Universal"),
+    ("0BSD", "Permission to use, copy, modify, and/or distribute this software for any purpose "
+             "with or without fee is hereby granted. THE SOFTWARE IS PROVIDED"),
+    ("ISC", "Permission to use, copy, modify, and/or distribute this software for any purpose "
+            "with or without fee is hereby granted, provided that the above copyright notice"),
+    ("Zlib", "altered source versions must be plainly marked as such"),
+    ("MIT", "without limitation the rights to use, copy, modify, merge, publish"),
+    ("BSD-4-Clause", "All advertising materials mentioning features or use of this software"),
+    ("BSD-3-Clause", "endorse or promote products derived"),
+    ("BSD-2-Clause", "Redistribution and use in source and binary forms"),
+]
+# `mayor: [las que absorbe]` — un texto que contiene al otro no son dos licencias, es una.
+ABSORBE = {
+    "BSD-4-Clause": ["BSD-3-Clause", "BSD-2-Clause"],
+    "BSD-3-Clause": ["BSD-2-Clause"],
+    "0BSD": ["ISC"],
+    "LGPL-3.0": ["GPL-3.0"],
+    "LGPL-2.1": ["GPL-2.0"],
+    "AGPL-3.0": ["GPL-3.0"],
+}
+# Excepciones que cambian el identificador y son invisibles al texto base de la licencia.
+EXCEPCIONES = [("Apache-2.0", "LLVM Exceptions", "Apache-2.0 WITH LLVM-exception")]
+# Las familias GNU son las únicas donde el TEXTO no dice la concesión: `-only` y `-or-later`
+# comparten COPYING palabra por palabra. Se resuelven aparte, en las cabeceras de los fuentes.
+AMBIGUAS = {"AGPL-3.0", "LGPL-3.0", "LGPL-2.1", "LGPL-2.0", "GPL-3.0", "GPL-2.0"}
+
+NOMBRES_LICENCIA = re.compile(r"^(COPYING|LICEN[CS]E)([.-].*)?$", re.I)
+# Ficheros donde SÍ vive la concesión. Se excluyen los de arriba a propósito (ver cabecera).
+EXT_FUENTE = (
+    ".c", ".h", ".cc", ".cpp", ".cxx", ".hpp", ".py", ".rs", ".go", ".sh",
+    ".am", ".ac", ".build", ".txt", ".md", ".in", ".pl", ".java", ".m4",
+)
+FRASE_LATER = re.compile(
+    r"(?:any|either)\s+later\s+version|(?:GPL|GPLv?[23])\+|version\s+[23](?:\.\d)?\s+or\s+(?:any\s+)?later",
+    re.I,
+)
+FRASE_ONLY = re.compile(
+    r"version\s+[23](?:\.\d)?\s+only|only\s+version\s+[23]|SPDX-License-Identifier:\s*\S*GPL-[23]\.0-only",
+    re.I,
+)
+def spdx_conocidos():
+    """Los identificadores cuyo TEXTO tenemos en `licenses/` (los baja licencias-textos.sh).
+
+    Es la lista blanca correcta y no una regex, por una razón que no es de comodidad: la obligación
+    al distribuir binarios es acompañarlos del TEXTO de la licencia. Un identificador del que no
+    tenemos el texto es un identificador que no podemos cumplir — así que declararlo no adelanta
+    nada y sí crea una afirmación que no se sostiene. Si aparece uno nuevo, el paso siguiente es
+    bajar su texto, no relajar la validación.
+
+    Esto es lo que atrapa el «GPL2+» que meson deja escribir: no es un SPDX, es taquigrafía.
+    """
+    d = os.path.join(RAIZ, "licenses")
+    if not os.path.isdir(d):
+        return set()
+    return {os.path.splitext(f)[0] for f in os.listdir(d) if f.endswith(".txt")}
+
+
+CONOCIDOS = spdx_conocidos()
+PEGAMENTO = {"AND", "OR", "WITH"}
+
+
+def spdx_valido(expr):
+    """Toda pieza de la expresión tiene que ser un identificador del que tengamos el texto."""
+    if not CONOCIDOS:
+        return True          # sin `licenses/` no hay con qué validar; no se inventa un veredicto
+    piezas = [x.strip("()") for x in expr.split() if x.upper() not in PEGAMENTO]
+    return bool(piezas) and all(x in CONOCIDOS for x in piezas)
+
+SPDX_HDR = re.compile(rb"SPDX-License-Identifier:\s*([A-Za-z0-9 .+()-]+)")
+
+# Plantilla de autotools/libtool: viene con el generador, NO la escribió el proyecto, y casi toda
+# es GPL-2.0-or-later. `nano` (GPL-3.0) salía «-or-later» citando el «either version 2» de su
+# `aclocal.m4`: una concesión que el proyecto nunca hizo. La cita tiene que ser del autor.
+BOILERPLATE = re.compile(
+    r"^(m4/|build-aux/|autom4te|aclocal\.m4$|configure$|configure\.ac$|ltmain\.sh$|"
+    r"config\.(guess|sub|rpath)$|install-sh$|missing$|depcomp$|compile$|test-driver$)"
+)
+# «either version 3 of the License» → 3. Sirve para exigir que la concesión hable de la MISMA
+# versión que el COPYING: un veredicto GPL-3.0 justificado con una frase que dice «version 2» es
+# incoherente, y era exactamente el caso de nano.
+VERSION_EN_FRASE = re.compile(r"version\s+([23])(?:\.\d)?|GPL-?v?([23])\+|GPL-([23])\.0", re.I)
+
+MAX_BYTES = 512 * 1024   # un fichero de fuente más grande que esto no es una cabecera de licencia
+MAX_MIEMBROS = 6000      # cota para no recorrer árboles gigantes enteros
+
+
+def recetas_sin_licencia():
+    out = []
+    for f in sorted(glob.glob(os.path.join(RAIZ, "recipes", "*.toml")) +
+                    glob.glob(os.path.join(RAIZ, "recipes", "*", "*.toml"))):
+        try:
+            d = tomllib.load(open(f, "rb"))
+        except Exception:
+            continue
+        if "name" in d and not d.get("license"):
+            out.append((f, d))
+    return out
+
+
+def sin_prefijo(nombre):
+    """`kcompletion-6.27.0/src/x.c` → `src/x.c`. El tar trae un directorio raíz."""
+    return nombre.split("/", 1)[1] if "/" in nombre else ""
+
+
+def una_linea(txt, tope=160):
+    """La cita se recorta a UNA línea: lleva texto de un fuente y sus saltos partían la fila del TSV
+    en varias, que es un fichero corrupto con aspecto de fichero bueno."""
+    t = " ".join(str(txt).split()).replace("\t", " ")
+    return t if len(t) <= tope else t[:tope - 1] + "…"
+
+
+def concuerda(cita, mayor):
+    """¿La cita habla de la misma versión que el COPYING? Si no, no justifica nada."""
+    if not cita:
+        return False
+    m = VERSION_EN_FRASE.search(cita)
+    if not m:
+        return True          # una cabecera SPDX sin número (p. ej. «GPL-2.0-only») ya es explícita
+    return (m.group(1) or m.group(2) or m.group(3)) == mayor
+
+
+def identificar(texto):
+    """Todas las licencias presentes en un texto, ya colapsadas las que se contienen."""
+    # Se quita el prefijo de comentario de cada línea antes de colapsar: el COPYING de libssh2 va
+    # dentro de un bloque `/* … */` y sus `*` partían la frase «endorse or * promote products»,
+    # que es justo la marca de la cláusula 3 ⇒ se declaraba BSD-2 un texto BSD-3.
+    t = " ".join(re.sub(r"^[\s*#/]+", "", l) for l in texto.split("\n"))
+    t = " ".join(t.split())
+    hits = [spdx for spdx, marca in FAMILIAS if " ".join(marca.split()) in t]
+    for mayor, menores in ABSORBE.items():
+        if mayor in hits:
+            hits = [h for h in hits if h not in menores]
+    for base, marca, con_excepcion in EXCEPCIONES:
+        if hits == [base] and marca in t:
+            hits = [con_excepcion]
+    return hits
+
+
+def declaracion_del_autor(ruta, texto):
+    """El SPDX que el propio autor escribe en su fichero de build. Evidencia de primera."""
+    base = os.path.basename(ruta)
+    if base == "Cargo.toml":
+        m = re.search(r'^\s*license\s*=\s*"([^"]+)"', texto, re.M)
+        if m:
+            return m.group(1), f"{ruta}: license = \"{m.group(1)}\""
+    if base == "meson.build":
+        m = re.search(r"license\s*:\s*\[?\s*'([^']+)'", texto)
+        if m:
+            return m.group(1), f"{ruta}: license: '{m.group(1)}'"
+    return None, None
+
+
+def analizar(tar_path):
+    """Un solo recorrido del tar. Devuelve (spdx, cita) o (None, pista)."""
+    autor = None
+    reuse = []            # ficheros de LICENSES/ (convención REUSE: el nombre ES el SPDX)
+    candidatos = []       # (prioridad, ruta, texto) de los ficheros de licencia de la RAÍZ
+    familia = None
+    familia_fichero = None
+    varias = None
+    cita_later = None
+    cita_only = None
+    spdx_headers = {}
+
+    try:
+        tf = tarfile.open(tar_path, "r:*")
+    except Exception as e:
+        return None, f"no se pudo abrir el tar: {e}"
+
+    with tf:
+        for i, m in enumerate(tf):
+            if i > MAX_MIEMBROS:
+                break
+            if not m.isfile() or m.size > MAX_BYTES:
+                continue
+            rel = sin_prefijo(m.name)
+            if not rel:
+                continue
+            base = os.path.basename(rel)
+
+            if rel.startswith("LICENSES/"):
+                reuse.append(base)
+                continue
+
+            # ⚠ SÓLO la raíz del árbol. `pigz` trae `zopfli/COPYING` (Apache-2.0) y el paquete NO es
+            # Apache: un COPYING de un subdirectorio es la licencia de una PIEZA EMPAQUETADA, no la
+            # del paquete. Tomar el primero que aparezca convierte una dep vendorizada en el
+            # veredicto del contenedor.
+            if "/" in rel:
+                es_licencia_raiz = False
+            else:
+                es_licencia_raiz = NOMBRES_LICENCIA.match(base) is not None
+
+            interesa_autor = rel in ("Cargo.toml", "meson.build")
+            es_licencia = es_licencia_raiz
+            es_fuente = rel.lower().endswith(EXT_FUENTE) or base in ("README", "AUTHORS", "NEWS")
+            if not (interesa_autor or es_licencia or es_fuente):
+                continue
+
+            try:
+                datos = tf.extractfile(m).read()
+            except Exception:
+                continue
+
+            if interesa_autor and autor is None:
+                lic, cita = declaracion_del_autor(rel, datos.decode("utf-8", "replace"))
+                if lic:
+                    autor = (lic, cita)
+
+            if es_licencia:
+                # `COPYING` a secas gana a `COPYING.OpenSSL`: el sufijado suele ser una EXCEPCIÓN o
+                # una licencia añadida, no la del paquete. socat es GPL-2.0 y su `COPYING.OpenSSL`
+                # es BSD — quedarse con el primero que aparezca lo declaraba BSD.
+                exacto = re.fullmatch(r"(COPYING|LICEN[CS]E)", base, re.I) is not None
+                candidatos.append((0 if exacto else 1, rel, datos.decode("utf-8", "replace")))
+                continue  # NUNCA se busca la concesión dentro del propio COPYING
+
+            if es_fuente and BOILERPLATE.match(rel):
+                es_fuente = False
+
+            if es_fuente:
+                h = SPDX_HDR.search(datos)
+                if h:
+                    ident = h.group(1).decode().strip()
+                    spdx_headers[ident] = spdx_headers.get(ident, 0) + 1
+                texto = datos.decode("utf-8", "replace")
+                if cita_later is None:
+                    mm = FRASE_LATER.search(texto)
+                    if mm:
+                        cita_later = f"{rel}: …{texto[max(0, mm.start()-60):mm.end()+20].strip()}…"
+                if cita_only is None:
+                    mm = FRASE_ONLY.search(texto)
+                    if mm:
+                        cita_only = f"{rel}: …{texto[max(0, mm.start()-60):mm.end()+20].strip()}…"
+
+    if candidatos:
+        exactos = [c for c in candidatos if c[0] == 0]
+        if not exactos and len(candidatos) > 1:
+            # ffmpeg trae COPYING.GPLv2, COPYING.GPLv3, COPYING.LGPLv2.1 y COPYING.LGPLv3 en la
+            # raíz: cuál rige lo deciden los FLAGS de la receta (`--enable-gpl`), no el árbol.
+            # Elegir el primero por orden alfabético sería inventar.
+            return None, ("la raíz trae " + str(len(candidatos)) + " ficheros de licencia y ninguno "
+                          "canónico (" + ", ".join(sorted(c[1] for c in candidatos))
+                          + "): decide la receta, no el árbol")
+        _, familia_fichero, texto = sorted(candidatos)[0]
+        hits = identificar(texto)
+        if len(hits) == 1:
+            familia = hits[0]
+        elif len(hits) > 1:
+            varias = hits
+
+    # 1. Declaración del autor — si es un SPDX de verdad. meson deja escribir cualquier cadena, y
+    # `gsd-schemas`/`libgdm` declaran «GPL2+», que NO es un identificador: sembrarlo sería meter
+    # basura con cara de dato. Se devuelve como pendiente, con lo que el autor puso a la vista.
+    if autor:
+        if spdx_valido(autor[0]):
+            return autor
+        return None, (f"el autor declara «{autor[0]}», que no es un SPDX del que tengamos texto "
+                      f"en licenses/ ({autor[1]})")
+
+    # 2. REUSE con un único fichero de licencia: el nombre es el SPDX.
+    únicos = sorted(set(os.path.splitext(x)[0] for x in reuse))
+    if len(únicos) == 1:
+        return únicos[0], f"LICENSES/{reuse[0]} (único, convención REUSE)"
+
+    # 3. Texto de la licencia + concesión en las cabeceras.
+    if familia:
+        if familia not in AMBIGUAS:
+            return familia, f"{familia_fichero} (texto de {familia})"
+        mayor = familia.rsplit("-", 1)[1][0]         # «GPL-3.0» → «3»
+        if not concuerda(cita_later, mayor):
+            cita_later = None
+        if not concuerda(cita_only, mayor):
+            cita_only = None
+        if cita_later and not cita_only:
+            return f"{familia}-or-later", f"{familia_fichero} + concesión en {cita_later}"
+        if cita_only and not cita_later:
+            return f"{familia}-only", f"{familia_fichero} + concesión en {cita_only}"
+        pista = f"{familia} ambigua"
+        if cita_later and cita_only:
+            pista += " · el árbol dice las DOS cosas, decide un humano"
+        else:
+            pista += " · sin concesión fuera del COPYING"
+        return None, pista
+
+    if varias:
+        return None, (f"{familia_fichero} mezcla {len(varias)} licencias ("
+                      + ", ".join(varias) + "): la expresión la compone un humano")
+    if únicos:
+        return None, "REUSE con varias licencias: " + ",".join(únicos)
+    if spdx_headers:
+        top = sorted(spdx_headers.items(), key=lambda x: -x[1])[:3]
+        return None, "sólo cabeceras SPDX: " + ", ".join(f"{k}×{v}" for k, v in top)
+    return None, "sin COPYING ni declaración en el árbol"
+
+
+def main():
+    objetivo = sys.argv[1] if len(sys.argv) > 1 else None
+    filas, pendientes, sin_tar = [], [], []
+
+    for f, d in recetas_sin_licencia():
+        nombre = d["name"]
+        if objetivo and nombre != objetivo and os.path.basename(f) != objetivo:
+            continue
+        sha = d.get("source", {}).get("sha256")
+        if not sha:
+            sin_tar.append((nombre, "fuente git/dir: no hay tarball que mirar"))
+            continue
+        tar_path = os.path.join(CACHE, sha + ".tar")
+        if not os.path.exists(tar_path):
+            sin_tar.append((nombre, "tarball no cacheado"))
+            continue
+        spdx, cita = analizar(tar_path)
+        if spdx:
+            filas.append((nombre, spdx, una_linea(cita)))
+        else:
+            pendientes.append((nombre, una_linea(cita)))
+
+    if objetivo:
+        for n, s, c in filas:
+            print(f"{n}\t{s}\n   evidencia: {c}")
+        for n, c in pendientes + sin_tar:
+            print(f"{n}\tPENDIENTE — {una_linea(c)}")
+        return
+
+    with open(SALIDA, "w") as fh:
+        fh.write("# licencias-evidencia.tsv — lo generó scripts/licencias-tarball.py desde el\n")
+        fh.write("# TARBALL PINEADO de cada receta. Tres columnas: paquete, SPDX, y la CITA que lo\n")
+        fh.write("# decidió. REVISAR A MANO antes de sembrar: es evidencia, no un veredicto firmado.\n")
+        for n, s, c in filas:
+            fh.write(f"{n}\t{s}\t{c}\n")
+
+    print(f"==> resueltas con evidencia del tarball: {len(filas)}")
+    for n, s, c in filas:
+        print(f"   {n:28s} {s:34s} {c[:70]}")
+    print(f"\n==> pendientes (hay tarball pero no alcanza para afirmar): {len(pendientes)}")
+    for n, c in pendientes:
+        print(f"   {n:28s} {c}")
+    print(f"\n==> sin tarball que mirar: {len(sin_tar)}")
+    for n, c in sin_tar:
+        print(f"   {n:28s} {c}")
+    print(f"\n==> escrito {os.path.relpath(SALIDA, RAIZ)} · NADA se tocó en recipes/")
+
+
+if __name__ == "__main__":
+    main()