> 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…
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.
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()