12 Commits
Author SHA1 Message Date
kj 3773303e6b release(crypto): v0.1.1 2026-10-05 18:10:42 -03:00
kj df976c7985 release(text): v0.1.1 2026-10-05 18:10:30 -03:00
kj 90e800ba1f feat(commands): describe catalog rows per component with package fallback 2026-10-05 18:10:30 -03:00
kj 40d853b2b0 release(commands): v0.2.1 2026-10-05 17:36:48 -03:00
kj a7f1a4dc60 release(test): v0.1.1 2026-10-05 17:36:43 -03:00
kj d11fd1989d release(http): v0.1.1 2026-10-05 17:36:38 -03:00
kj 6925b814e6 fix(commands): surface core fetch errors, run post-install in the project, exit 1 on invalid project command; document compat flow 2026-10-05 17:36:08 -03:00
kj eb5e6eeed4 fix(test): self-register Tests\ autoload and migrations dir in bootstrap
The harness failed under a real phpunit run because nothing mapped
Tests\ -> tests/ and Commands\Migration needs migrations/ to exist.
Declare commands:migration as the actual dependency.
2026-10-05 17:36:08 -03:00
kj 0082cbe3f4 fix(http): drop deprecated curl_close calls; resolve Proxy destination lazily
PHP 8.5 deprecates curl_close() (no-op since 8.0) and Libs\Proxy fataled
on class load because SOURCE_URL was evaluated in a static property
initializer. Stamps http with per-component core data for load-verified
clients (aria2, proxy, compreFace).
2026-10-05 17:36:08 -03:00
kj 0b98b7a26b feat(compat): sweep of components against the real core with strict diagnostics 2026-10-05 17:36:08 -03:00
kj 1127272330 refactor(release): unify ask via global ANSWER variable 2026-10-05 16:29:16 -03:00
kj 6e7b572935 feat(release): introduce interactive mode 2026-10-05 16:22:17 -03:00
42 changed files with 1284 additions and 142 deletions
+7 -1
View File
@@ -4,7 +4,7 @@
CONFIG := packages/commands/src/Toolbox/Config.php
VERSION := $(shell grep -oP "const VERSION = '\K[^']+" $(CONFIG))
.PHONY: help test build publish check clean
.PHONY: help test build compat release publish check clean
help: ## Muestra esta ayuda
@grep -hE '^[a-z-]+:.*## ' $(MAKEFILE_LIST) | sed -E 's/^([a-z-]+):.*## (.+)$$/ make \1 \xe2\x80\x94 \2/'
@@ -15,6 +15,12 @@ test: ## Corre la suite de pruebas (harness propio, sin phpunit)
build: ## Construye dist/duckbrain.phar y su .sha256
scripts/build-phar
compat: ## Barrido de compatibilidad contra el core real (requiere red)
scripts/compat-sweep $(if $(ARGS),$(ARGS),)
release: ## Asistente interactivo para versionar un paquete (scripts/release)
./scripts/release $(if $(ARGS),$(ARGS),)
publish: build ## Crea/mueve la release v$(VERSION) en Forgejo y sube los assets
scripts/publish-phar
+101 -9
View File
@@ -41,7 +41,11 @@ repositorio.
#+BEGIN_SRC text
duckbrain-toolbox/
bin/duckbrain instalador global (bootstrap)
scripts/release escritor único de versión + tag
src/autoload.php mapea Toolbox\ -> packages/commands/src/Toolbox/
scripts/release version + tag + push (interactivo o directo)
scripts/build-phar construye dist/duckbrain.phar autocontenido
scripts/publish-phar publica el phar como release en Forgejo
Makefile test / build / release / publish / check / clean
packages/
commands/ cli, migration, toolbox
htmx/ htmlComponent, htmx
@@ -73,6 +77,25 @@ duckbrain add crypto htmx # copias reales, listas para commitear
propios addons sin el instalador global, instala el componente =commands:toolbox=
(es un =add= más, no un requisito). A partir de ahí =./duckbrain= funciona solo.
Alternativa sin clone del repo: descargar el *phar autocontenido* publicado como
release y ejecutarlo directamente (los enlaces simbólicos no valen:
=self-update= reemplaza el binario con =rename()= sobre su propia ruta):
#+BEGIN_SRC sh
VERSION=$(curl -s https://git.kj2.me/api/v1/repos/kj/duckbrain-toolbox/releases \
| jq -r '.[].tag_name' | grep -E '^v[0-9]' | sort -V | tail -1)
curl -fsSL "https://git.kj2.me/kj/duckbrain-toolbox/releases/download/$VERSION/duckbrain.phar" \
-o ~/.local/bin/duckbrain
chmod +x ~/.local/bin/duckbrain
duckbrain --version # ya es el instalador global; se actualiza solo:
duckbrain self-update
#+END_SRC
(Para probar el tuyo local sin release, usa =cp dist/duckbrain.phar ~/.local/bin/duckbrain=
en lugar del =curl=; =make build= + =ln -s= solo sirve mientras no llames a
=self-update=.)
* Comandos
** =duckbrain install [dir]=
@@ -108,7 +131,7 @@ duckbrain update --dry-run # muestra el plan sin escribir
- =list=: core y componentes instalados con versión y modo.
- =doctor=: matriz de compatibilidad, sin efectos secundarios.
- =catalog=: paquetes y componentes disponibles.
- =catalog=: paquetes y componentes disponibles, cada uno con su descripción.
- =adopt=: registra copias hechas a mano en proyectos antiguos.
* Manifiesto =duckbrain.json=
@@ -210,20 +233,89 @@ Nunca bloquea: se confirma con =--yes=. Al core se le sigue la pista por sus
tags semver (=vX.Y.Z=, empezando por =v0.1.0=); si no hay tags, la versión es
desconocida y se fija por commit.
Los valores =tested= de los manifiestos no son decorativos: los escribe sólo
quien corrió el *barrido de compatibilidad* (=make compat=), que monta un
proyecto temporal por paquete contra el core real publicado y lo ejercita por
HTTP (migraciones sobre sqlite, runner phpunit real; los clientes con servicio
externo — =aria2=, =proxy=, =compreFace= — sólo a nivel de carga/instantiación,
consignado así en =tests/compat/report.json=). Cualquier
=Deprecated=/=Warning=/=Notice= durante el barrido cuenta como fallo.
Un test de la suite (=group11=) exige que cada =tested= declarado tenga su =ok=
en ese reporte y que no se estampen componentes deliberadamente sin datos
(=commands:cli=/=commands:toolbox=, que son el instalador y no consumen el
core). Al aparecer un tag nuevo del core, el ritual es: =make compat= → arreglar
lo que falle → estampar → =make release=.
* Versionado y releases
Cada paquete se versiona de forma independiente con tags prefijados
(=http-v0.1.0=, =crypto-v0.1.0=…). El tag es el descubrimiento; el *commit* es la
identidad, así que el lock pinea commits y no se rompe si un tag se mueve.
Hay *dos* sistemas de versión y no hay que confundirlos:
El release lo hace un único script:
1. *Paquetes/componentes* — la versión vive en =packages/<paquete>/duckbrain.json= y
se versiona de forma independiente con tags prefijados (=http-v0.1.0=,
=crypto-v0.1.0=…). La única forma de cambiarla es el asistente:
#+BEGIN_SRC sh
make release # interactivo: elige
# paquete e incremento,
# muestra cambios
# pendientes y confirma
scripts/release <paquete> <patch|minor|major> [--yes] # modo directo
#+END_SRC
Bumpea el manifiesto, commitea, crea el tag y lo publica. Al liberar
=commands= sincroniza también =Config::VERSION= en el mismo commit. El tag es el
descubrimiento; el *commit* es la identidad, así que el lock pinea commits y no
se rompe si un tag se mueve. Las versiones retiradas se listan en
=yanked.json=; las prereleases se omiten salvo =--pre=. Para detectar bumps
olvidados: =git log <paquete>-v<última>..HEAD -- packages/<paquete>= (vacío = al día).
2. *Instalador/phar* — =Config::VERSION= (=packages/commands/src/Toolbox/Config.php=):
es lo que imprime la CLI y la referencia con la que =self-update= compara las
releases del servidor. No se edita a mano; la mantiene el release de
=commands=. Se publica con =make publish= (ver *Makefile*).
* Makefile
=make help= lista los objetivos:
| Objetivo | Qué hace |
|--------------+------------------------------------------------------------------------------------------------------------------------|
| =make test= | Corre la suite (=php tests/run.php=). En verde antes de commitear. |
| =make build= | Genera =dist/duckbrain.phar= + =dist/duckbrain.phar.sha256=. |
| =make compat= | Barrido de compatibilidad contra el core real (=scripts/compat-sweep=); requiere red. =ARGS="--only pkg"= para un paquete. |
| =make release= | Asistente interactivo de releases de paquetes (=ARGS="pkg minor --yes"= para modo directo). |
| =make publish= | =build= + publica la release =v$(Config::VERSION)= con los assets. Requiere =FORGEJO_TOKEN=. |
| =make check= | =self-update --check= contra el servidor: =actual/disponible=, exit 1 si hay nuevo. |
| =make clean= | Borra =dist/=. |
El token para publicar es un token personal de la API de Forgejo
(*Ajustes → Aplicaciones*, scope =write:repository=, con caducidad y revocable).
Los PAT de Forgejo no pueden limitarse a un solo repositorio: para aislar el
riesgo, crea un *usuario bot* con acceso Write solo en =duckbrain-toolbox= y usa
su token. =dist/= está en gitignore: nunca se commitea.
* self-update (el phar)
#+BEGIN_SRC sh
scripts/release <paquete> <patch|minor|major>
duckbrain self-update # se auto-reemplaza con la última release
duckbrain self-update --check # actual vs disponible (exit 1 si hay nueva)
duckbrain self-update --force # reemplaza aunque esté al día
#+END_SRC
Bumpea la versión del manifiesto, commitea, crea el tag y publica. Las versiones
retiradas se listan en =yanked.json=; las prereleases se omiten salvo =--pre=.
Lee la lista de releases
(=https://git.kj2.me/api/v1/repos/kj/duckbrain-toolbox/releases=, sobrescribible
con =DUCKBRAIN_UPDATE_URL=) y elige el *mayor tag semver* con la misma clase que
el core usa con sus tags; tags que no son semver y drafts se ignoran. No
existe la ruta =/releases/latest/download/= en Forgejo, así que no se depende de
ella: basta con subir =make publish= la versión real =vX.Y.Z=. La descarga se
verifica contra el =.sha256= publicado y el reemplazo es atómico (temp + rename)
sobre =Phar::running(false)=. Si =Config::VERSION >= remota= no se descarga nada.
Para tocar el instalador con el phar en mente: =build-phar= ya ejecuta con
=php -d phar.readonly=0= (crear phars está bloqueado por defecto), el código que
corre *dentro* del phar no debe usar =glob()= (no soporta el stream =phar://=;
usa =DirectoryIterator= / =file_get_contents()=), y la raíz del toolbox se
resuelve con =Config::toolboxDir()=, que ya antepone =phar://= cuando toca.
* Pruebas
+4 -2
View File
@@ -28,7 +28,8 @@ if ($command === null || in_array($command, $toolboxCommands, true)) {
$coreCommand = 'Commands\\' . ucfirst($command);
if (!class_exists($coreCommand) && !file_exists(ROOT_CORE . '/Commands/' . ucfirst($command) . '.php')) {
exit("Invalid command.\n");
fwrite(STDERR, "Invalid command.\n");
exit(1);
}
$cli = new $coreCommand();
@@ -36,5 +37,6 @@ $cli = new $coreCommand();
if ($cli instanceof Interfaces\CommandInterface) {
call_user_func_array([$cli, 'run'], array_slice($argv, 2));
} else {
exit("Invalid command.\n");
fwrite(STDERR, "La clase $coreCommand no implementa CommandInterface.\n");
exit(1);
}
+5 -1
View File
@@ -1,6 +1,6 @@
{
"name": "commands",
"version": "0.2.0",
"version": "0.2.1",
"description": "CLI y migraciones para Duckbrain",
"components": {
"cli": {
@@ -16,6 +16,10 @@
],
"require": {
"commands:cli": "*"
},
"core": {
"requires": ">=0.1",
"tested": "0.1"
}
},
"toolbox": {
@@ -31,7 +31,7 @@ final class CatalogCommand extends Command
$first ? $package->name . ' (' . $package->version . ')' : '',
$name,
$installed,
$first ? $package->description : '',
$package->descriptionOf($name) ?? '',
];
$first = false;
}
@@ -15,6 +15,7 @@ final class Component
public readonly array $requires = [],
public readonly ?string $coreRequires = null,
public readonly ?string $coreTested = null,
public readonly ?string $description = null,
public readonly ?array $destinations = null,
public readonly bool $override = false,
) {
@@ -51,12 +52,22 @@ final class Component
}
}
$description = null;
if (isset($data['description'])) {
if (!is_string($data['description'])) {
throw new ToolboxException("El componente '{$name}' tiene un 'description' inválido");
}
$description = $data['description'];
}
return new self(
name: $name,
files: array_values($data['files']),
requires: $requires,
coreRequires: $data['core']['requires'] ?? null,
coreTested: $data['core']['tested'] ?? null,
description: $description,
destinations: $destinations,
override: (bool) ($data['override'] ?? false),
);
+1 -1
View File
@@ -8,7 +8,7 @@ namespace Toolbox;
*/
final class Config
{
public const VERSION = '0.2.0';
public const VERSION = '0.2.1';
public static function toolboxUrl(): string
{
@@ -36,7 +36,12 @@ final class CoreProvider
throw new ToolboxException('No se pudo clonar el core: ' . implode("\n", $out));
}
} else {
exec('git -C ' . escapeshellarg($dir) . ' fetch -q --tags 2>&1');
exec('git -C ' . escapeshellarg($dir) . ' remote set-url origin ' . escapeshellarg($url) . ' 2>&1');
$out = [];
exec('git -C ' . escapeshellarg($dir) . ' fetch -q --tags 2>&1', $out, $code);
if ($code !== 0) {
throw new ToolboxException('No se pudo actualizar el core: ' . implode("\n", $out));
}
}
$provider = new self($dir);
+2 -1
View File
@@ -191,7 +191,8 @@ final class Installer
$manifest->postInstall,
$this->console,
(bool) ($options['yes'] ?? false),
$options['postRunner'] ?? null
$options['postRunner'] ?? null,
$this->root,
);
}
}
@@ -157,4 +157,17 @@ final class Manifest
return $this->coreTested;
}
/**
* Descripcion efectiva: la propia del componente o, en su ausencia, la del
* paquete (mismo patrón de herencia que los rangos de core).
*/
public function descriptionOf(?string $component = null): ?string
{
if ($component !== null) {
return $this->component($component)->description ?? $this->description;
}
return $this->description;
}
}
+17 -1
View File
@@ -11,16 +11,27 @@ final class PostInstall
/**
* @param list<string> $commands
* @param callable(string):int|null $runner
* @param string|null $cwd Directorio de trabajo del proyecto: los
* comandos se ejecutan alli, no donde arranco
* el instalador.
* @return list<string> comandos ejecutados
*/
public static function run(
array $commands,
Console $console,
bool $assumeYes = false,
?callable $runner = null
?callable $runner = null,
?string $cwd = null
): array {
$executed = [];
$previous = null;
if ($cwd !== null && is_dir($cwd)) {
$previous = getcwd();
chdir($cwd);
}
try {
foreach ($commands as $command) {
$console->out(' post-install: ' . $command);
@@ -41,6 +52,11 @@ final class PostInstall
$executed[] = $command;
}
} finally {
if ($previous !== null) {
chdir($previous);
}
}
return $executed;
}
+29 -7
View File
@@ -1,13 +1,35 @@
{
"name": "crypto",
"version": "0.1.0",
"description": "Criptografía y segundo factor (Crypto, Nonce, TwoFactorAuthenticator)",
"core": { "requires": ">=0.1", "tested": "0.1" },
"version": "0.1.1",
"description": "Criptograf\u00eda y segundo factor (Crypto, Nonce, TwoFactorAuthenticator)",
"core": {
"requires": ">=0.1",
"tested": "0.1"
},
"php": ">=8.1",
"components": {
"crypto": { "files": ["src/Libs/Crypto.php"] },
"nonce": { "files": ["src/Libs/Nonce.php"] },
"twoFactor": { "files": ["src/Libs/TwoFactorAuthenticator.php"] }
"crypto": {
"files": [
"src/Libs/Crypto.php"
],
"description": "Cifrado sim\u00e9trico con salida hex/base64"
},
"default": ["crypto", "nonce", "twoFactor"]
"nonce": {
"files": [
"src/Libs/Nonce.php"
],
"description": "Generaci\u00f3n y verificaci\u00f3n de tokens de un solo uso"
},
"twoFactor": {
"files": [
"src/Libs/TwoFactorAuthenticator.php"
],
"description": "TOTP compatible con Google Authenticator"
}
},
"default": [
"crypto",
"nonce",
"twoFactor"
]
}
+45 -9
View File
@@ -1,17 +1,53 @@
{
"name": "http",
"version": "0.1.0",
"version": "0.1.1",
"description": "Utilidades HTTP (CurlRequest, Aria2, Proxy, CompreFace)",
"core": { "requires": ">=0.1", "tested": "0.1" },
"core": {
"requires": ">=0.1",
"tested": "0.1"
},
"php": ">=8.1",
"components": {
"curlRequest": { "files": ["src/Libs/CurlRequest.php"] },
"aria2": { "files": ["src/Libs/Aria2.php"] },
"proxy": { "files": ["src/Libs/Proxy.php"] },
"compreFace": {
"files": ["src/Libs/CompreFace.php"],
"require": { "http:curlRequest": ">=0.1" }
"curlRequest": {
"files": [
"src/Libs/CurlRequest.php"
]
},
"aria2": {
"files": [
"src/Libs/Aria2.php"
],
"core": {
"requires": ">=0.1",
"tested": "0.1"
}
},
"default": ["curlRequest", "aria2", "proxy", "compreFace"]
"proxy": {
"files": [
"src/Libs/Proxy.php"
],
"core": {
"requires": ">=0.1",
"tested": "0.1"
}
},
"compreFace": {
"files": [
"src/Libs/CompreFace.php"
],
"require": {
"http:curlRequest": ">=0.1"
},
"core": {
"requires": ">=0.1",
"tested": "0.1"
}
}
},
"default": [
"curlRequest",
"aria2",
"proxy",
"compreFace"
]
}
-10
View File
@@ -40,16 +40,6 @@ class Aria2
]);
}
/**
* Destructor
*
* @return void
*/
public function __destruct()
{
curl_close($this->ch);
}
/**
* Allows starting a batch call.
*
-4
View File
@@ -52,10 +52,6 @@ class CurlRequest
$this->responseBody = curl_exec($handler);
$this->responseHeaders = curl_getinfo($handler, CURLINFO_HEADER_OUT);
$this->statusCode = curl_getinfo($handler, CURLINFO_HTTP_CODE);
// throw new Exception('Error al realizar la solicitud. '. curl_error($handler));
curl_close($handler);
}
/**
+19 -4
View File
@@ -22,9 +22,25 @@ class Proxy
'Location',
];
public static $proxy_base_url_canonical = '/';
public static $destination_host = SOURCE_URL;
public static ?string $destination_host = null;
protected static $filters = [];
/**
* Host destino efectivo: el asignado explicitamente o la constante
* SOURCE_URL del proyecto si existe. Se resuelve tarde para que la
* simple carga de la clase no falle sin configuracion.
*
* @return string
*/
protected static function destinationHost(): string
{
if (static::$destination_host !== null) {
return static::$destination_host;
}
return defined('SOURCE_URL') ? (string) SOURCE_URL : '';
}
/**
* Adds a callable (AKA filter) that will receive the content
* and should return a string.
@@ -76,7 +92,7 @@ class Proxy
}
// Generate the final URL
$proxy_request_url = static::$destination_host . $proxy_request_uri;
$proxy_request_url = static::destinationHost() . $proxy_request_uri;
// cURL initialization
$ch = curl_init();
@@ -121,11 +137,10 @@ class Proxy
// Execute the request
$res = curl_exec($ch);
curl_close($ch);
// Parse the response
list($headers, $body) = explode("\r\n\r\n", $res, 2);
$headers = str_replace(static::$destination_host, '/', $headers);
$headers = str_replace(static::destinationHost(), '/', $headers);
$headers = explode("\r\n", $headers);
$hs = [];
+15 -5
View File
@@ -1,16 +1,26 @@
{
"name": "test",
"version": "0.1.0",
"version": "0.1.1",
"description": "Kit de pruebas PHPUnit para Duckbrain",
"core": { "requires": ">=0.1", "tested": "0.1" },
"core": {
"requires": ">=0.1",
"tested": "0.1"
},
"php": ">=8.1",
"components": {
"harness": {
"files": ["phpunit.xml", "tests"],
"require": { "commands:cli": ">=0.1" }
"files": [
"phpunit.xml",
"tests"
],
"require": {
"commands:migration": ">=0.1"
}
}
},
"default": ["harness"],
"default": [
"harness"
],
"post-install": [
"composer require --dev phpunit/phpunit:^11.0 fakerphp/faker",
"composer dump-autoload"
+17
View File
@@ -6,6 +6,18 @@ use Libs\Loader;
require_once __DIR__ . '/../vendor/autoload.php';
// Mapeo PSR-4 propio del arbol de pruebas (Tests\ -> tests/) sin exigir que el
// proyecto declare autoload-dev en su composer.json.
spl_autoload_register(function (string $class): void {
if (!str_starts_with($class, 'Tests\\')) {
return;
}
$file = __DIR__ . '/' . str_replace('\\', '/', substr($class, 6)) . '.php';
if (is_file($file)) {
require_once $file;
}
});
// ============================================
// IMPORTANT: Definir constantes ANTES de cargar config.php
// para sobreescribir la configuración para definir una
@@ -20,6 +32,11 @@ define('DB_PASS', '');
// Incluir config.php con el autoloader
@require_once __DIR__ . '/../autoload.php';
// El ledger de migraciones (Commands\Migration) necesita el directorio.
if (!is_dir(ROOT_DIR . '/migrations')) {
mkdir(ROOT_DIR . '/migrations', 0777, true);
}
// Router autoload
Loader::load(ROOT_CORE . '/Routers/');
+21 -5
View File
@@ -1,12 +1,28 @@
{
"name": "text",
"version": "0.1.0",
"version": "0.1.1",
"description": "Filtros y reemplazos de texto (Filter, Replacement)",
"core": { "requires": ">=0.1", "tested": "0.1" },
"core": {
"requires": ">=0.1",
"tested": "0.1"
},
"php": ">=8.1",
"components": {
"filter": { "files": ["src/Libs/Filter.php"] },
"replacement": { "files": ["src/Libs/Replacement.php"] }
"filter": {
"files": [
"src/Libs/Filter.php"
],
"description": "Filtros de contenido extensibles por contexto y prioridad"
},
"default": ["filter", "replacement"]
"replacement": {
"files": [
"src/Libs/Replacement.php"
],
"description": "B\u00fasqueda y reemplazo de patrones sobre contenido"
}
},
"default": [
"filter",
"replacement"
]
}
+197
View File
@@ -0,0 +1,197 @@
#!/usr/bin/env bash
# Barrido de compatibilidad componente <-> core real (change verify-core-compat).
# Monta un proyecto temporal por paquete contra el core real publicado, lo
# ejercita segun su nivel de evidencia (tests/compat/levels.json) con criterios
# estrictos (cualquier Deprecated/Warning/Notice/Fatal cuenta FAIL) y escribe
# tests/compat/report.json.
#
# Uso: scripts/compat-sweep [--only <paquete> ...]
# Env: DUCKBRAIN_CORE_URL repositorio del core (default: Config::coreUrl())
# DUCKBRAIN_CACHE_DIR caché donde CoreProvider mantiene el clon del core
set -euo pipefail
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
COMPAT="$ROOT/tests/compat"
LEVELS="$COMPAT/levels.json"
REPORT="$COMPAT/report.json"
WORK="$(mktemp -d)"
trap 'rm -rf "$WORK"' EXIT
ONLY=()
args=("$@")
i=0
while [ "$i" -lt "${#args[@]}" ]; do
case "${args[i]}" in
--only) i=$((i + 1)); ONLY+=("${args[i]}") ;;
*) echo "Uso: scripts/compat-sweep [--only <paquete> ...]" >&2; exit 2 ;;
esac
i=$((i + 1))
done
# --- resolver el core real (clon + checkout del mayor tag semver) --------------
CORE_INFO="$(php -d error_reporting=-1 -r '
require "'"$ROOT"'/src/autoload.php";
try {
$p = Toolbox\CoreProvider::fromUrl(
Toolbox\Config::coreUrl(), Toolbox\Config::cacheDir(), new Toolbox\Console()
);
$v = $p->version();
if ($v === null) {
fwrite(STDERR, "El core no tiene tags semver publicados; nada que verificar.\n");
exit(1);
}
echo $p->dir(), "\n", $v, "\n", (string) $p->commit(), "\n";
} catch (Throwable $e) {
fwrite(STDERR, "No se pudo acceder al core real: " . $e->getMessage() . "\n");
exit(1);
}' 2>"$WORK/core.err")" || {
echo "== ERROR: barrido abortado ==" >&2
cat "$WORK/core.err" >&2
exit 1
}
CORE_DIR="$(printf '%s\n' "$CORE_INFO" | sed -n 1p)"
CORE_VER="$(printf '%s\n' "$CORE_INFO" | sed -n 2p)"
CORE_SHA="$(printf '%s\n' "$CORE_INFO" | sed -n 3p)"
echo "== core real: v$CORE_VER ($CORE_SHA) en $CORE_DIR =="
echo
LEVELS_PATH="$LEVELS" php -r '
$levels = json_decode((string) file_get_contents(getenv("LEVELS_PATH")), true);
echo implode("\n", array_keys($levels)), "\n";' > "$WORK/pkgs"
RESULTS="$WORK/results"
: > "$RESULTS"
FAILED=0
wanted() {
[ "${#ONLY[@]}" -eq 0 ] && return 0
local w
for w in "${ONLY[@]}"; do [ "$w" = "$1" ] && return 0; done
return 1
}
stop_server() {
if [ -n "${SERVER_PID:-}" ]; then
kill "$SERVER_PID" 2>/dev/null || true
wait "$SERVER_PID" 2>/dev/null || true
SERVER_PID=""
fi
}
# --- por paquete ---------------------------------------------------------------
while read -r PKG; do
wanted "$PKG" || continue
PROJ="$WORK/proj-$PKG"
LOG="$WORK/$PKG.server.log"
echo "── $PKG"
php -d error_reporting=-1 "$ROOT/bin/duckbrain" install "$PROJ" --core-dir="$CORE_DIR" --yes >/dev/null
php -d error_reporting=-1 "$ROOT/bin/duckbrain" add "$PKG" --root="$PROJ" --yes >/dev/null \
|| { echo "$PKG:add|fail" >> "$RESULTS"; echo " add fallo"; FAILED=1; continue; }
# Config del proyecto temporal: sqlite, clave para crypto y db local.
PROJ_PATH="$PROJ" php -r '
$proj = getenv("PROJ_PATH");
$f = "$proj/config.php";
$cfg = (string) file_get_contents($f);
$cfg = str_replace("define(\x27DB_TYPE\x27, \x27mysql\x27);", "define(\x27DB_TYPE\x27, \x27sqlite\x27);", $cfg);
$cfg = str_replace("define(\x27DB_NAME\x27, \x27\x27);", "define(\x27DB_NAME\x27, \x27$proj/app.sqlite\x27);", $cfg);
if (!str_contains($cfg, "PRIVATE_KEY")) {
$cfg .= "\ndefine(\x27PRIVATE_KEY\x27, \x27compat-sweep-clave-de-prueba-32!\x27);\n";
}
file_put_contents($f, $cfg);'
# Router de compatibilidad del fixture (si lo hay).
if [ -f "$COMPAT/$PKG/router.php" ]; then
mkdir -p "$PROJ/src/Routers"
sed "s|@COMPAT@|$COMPAT/$PKG|g" "$COMPAT/$PKG/router.php" > "$PROJ/src/Routers/Compat.php"
fi
# Servidor con diagnósticos al máximo y capturados.
BASE=""
: > "$LOG"
for PORT in $(seq 18100 18120); do
PHP_CLI_SERVER_WORKERS=4 php -d error_reporting=-1 -d display_errors=1 \
-d log_errors=1 -d error_log="$LOG" -S "127.0.0.1:$PORT" -t "$PROJ" "$PROJ/index.php" \
>/dev/null 2>&1 &
SERVER_PID=$!
for _ in 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20; do
sleep 0.15
if curl -s -o /dev/null "http://127.0.0.1:$PORT/compat/ping"; then
BASE="http://127.0.0.1:$PORT"
break 2
fi
done
stop_server
done
if [ -z "$BASE" ]; then
echo " no hubo servidor en 18100-18120" >&2
echo "$PKG:server|fail" >> "$RESULTS"; FAILED=1; continue
fi
# Aserciones del fixture (si lo hay; los niveles "none" no tienen).
if [ -f "$COMPAT/$PKG/assert.sh" ]; then
BASE="$BASE" PROJ="$PROJ" PKG="$PKG" LOG="$LOG" RESULTS="$RESULTS" COMPAT="$COMPAT" \
bash "$COMPAT/$PKG/assert.sh" || true
fi
stop_server
# Criterio estricto: cualquier diagnostico en el log del servidor es fallo.
if grep -qE 'Deprecated|Warning:|Notice:|Fatal error|Parse error' "$LOG"; then
echo " diagnostico PHP detectado en el servidor (ver $LOG):"
grep -E 'Deprecated|Warning:|Notice:|Fatal error|Parse error' "$LOG" | sed 's/^/ /'
FAILED=1
fi
done < "$WORK/pkgs"
# --- merge de resultados con levels.json y reporte ------------------------------
LEVELS_PATH="$LEVELS" RESULTS_PATH="$RESULTS" REPORT_PATH="$REPORT" \
CORE_VER="$CORE_VER" CORE_SHA="$CORE_SHA" php -r '
$levels = json_decode((string) file_get_contents(getenv("LEVELS_PATH")), true);
$results = [];
foreach (file(getenv("RESULTS_PATH"), FILE_IGNORE_NEW_LINES) as $line) {
[$id, $status] = array_pad(explode("|", $line, 2), 2, "");
$results[$id] = $status;
}
// Corre parciales (--only): hereda del reporte previo los ok del mismo core.
$prior = is_file(getenv("REPORT_PATH"))
? (json_decode((string) file_get_contents(getenv("REPORT_PATH")), true) ?: [])
: [];
$stale = ($prior["core"]["version"] ?? null) !== getenv("CORE_VER");
$out = [];
$fail = 0;
foreach ($levels as $pkg => $comps) {
foreach ($comps as $comp => $level) {
$reason = null;
if (is_array($level)) { $reason = $level["reason"] ?? null; $level = $level["level"]; }
$id = "$pkg:$comp";
$status = $results[$id] ?? ($level === "none" ? "skipped" : "not_attempted");
if ($status === "not_attempted" && !$stale && ($prior["components"][$id]["status"] ?? "") === "ok") {
$status = "ok";
}
if (!in_array($status, ["ok", "skipped"], true)) { $fail++; }
$out[$id] = array_filter([
"status" => $status,
"level" => $level,
"reason" => $reason,
], fn ($v) => $v !== null);
}
}
$report = [
"core" => ["version" => getenv("CORE_VER"), "commit" => getenv("CORE_SHA")],
"generated" => gmdate("Y-m-d\TH:i:s\Z"),
"components" => $out,
];
file_put_contents(getenv("REPORT_PATH"), json_encode($report, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES) . "\n");
foreach ($out as $id => $r) {
printf(" %-24s %-13s %s%s\n", $id, $r["status"], $r["level"], isset($r["reason"]) ? " (" . $r["reason"] . ")" : "");
}
exit($fail === 0 ? 0 : 1);'
STATUS=$?
if [ "$STATUS" = 0 ]; then
echo "OK: barrido de compatibilidad completo contra core v$CORE_VER"
else
echo "FAIL: hay componentes sin verificar o con fallos (ver $REPORT)" >&2
fi
exit $STATUS
+142 -37
View File
@@ -1,46 +1,142 @@
#!/usr/bin/env bash
# Escritor único de versión + tag de un paquete del toolbox.
# Uso: scripts/release <paquete> <patch|minor|major>
# Unico escritor de version + tag + push de un paquete del toolbox.
# Interactivo: scripts/release (o `make release`)
# Directo: scripts/release <paquete> <patch|minor|major> [--yes]
# Al liberar `commands` sincroniza Config::VERSION con la version del manifiesto.
set -euo pipefail
if [ "$#" -lt 2 ]; then
echo "Uso: scripts/release <paquete> <patch|minor|major>" >&2
exit 1
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
CONFIG_PHP="$ROOT/packages/commands/src/Toolbox/Config.php"
die() { echo "$*" >&2; exit 1; }
usage() {
echo "Uso: scripts/release [<paquete> <patch|minor|major>] [--yes]" >&2
echo " sin argumentos: modo interactivo (o 'make release')" >&2
}
packages() {
local f
for f in "$ROOT"/packages/*/duckbrain.json; do
[ -f "$f" ] && basename "$(dirname "$f")"
done
}
manifest() { echo "$ROOT/packages/$1/duckbrain.json"; }
version_of() {
php -r '$d=json_decode(file_get_contents($argv[1]),true); echo $d["version"] ?? "";' "$(manifest "$1")"
}
bump() {
php -r '
$v = ltrim($argv[1], "vV");
$p = array_map("intval", array_pad(explode(".", preg_replace("/[-+].*/", "", $v)), 3, 0));
switch ($argv[2]) {
case "major": $p[0]++; $p[1] = 0; $p[2] = 0; break;
case "minor": $p[1]++; $p[2] = 0; break;
default: $p[2]++;
}
echo implode(".", $p);' "$1" "$2"
}
ANSWER=""
ask() {
printf '%s' "$1" >&2
read -r ANSWER || die "entrada agotada; pasa los argumentos o correlo en un TTY"
}
ASSUME_YES=0
POSITIONAL=()
for arg in "$@"; do
case "$arg" in
--yes|-y) ASSUME_YES=1 ;;
*) POSITIONAL+=("$arg") ;;
esac
done
confirm() {
[ "$ASSUME_YES" = "1" ] && return 0
ask "$1 [Y/n] "
case "${ANSWER,,}" in ""|y|yes) return 0 ;; *) return 1 ;; esac
}
# --- eleccion de paquete -----------------------------------------------------
if [ "${#POSITIONAL[@]}" -ge 1 ]; then
PACKAGE="${POSITIONAL[0]}"
else
mapfile -t PKGS < <(packages)
[ "${#PKGS[@]}" -gt 0 ] || die "No hay paquetes en $ROOT/packages/"
echo "Paquetes del toolbox:"
for i in "${!PKGS[@]}"; do
printf ' %2d) %-10s v%s\n' "$((i + 1))" "${PKGS[i]}" "$(version_of "${PKGS[i]}")"
done
while :; do
ask 'Elegir paquete (numero o nombre): '; R="$ANSWER"
if [[ "$R" =~ ^[0-9]+$ ]] && [ "$R" -ge 1 ] && [ "$R" -le "${#PKGS[@]}" ]; then
PACKAGE="${PKGS[R - 1]}"
break
fi
printf '%s\n' "${PKGS[@]}" | grep -qxF "$R" && { PACKAGE="$R"; break; }
echo " opcion invalida" >&2
done
fi
PACKAGE="$1"
TYPE="$2"
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
MANIFEST="$ROOT/packages/$PACKAGE/duckbrain.json"
MANIFEST="$(manifest "$PACKAGE")"
[ -f "$MANIFEST" ] || die "No existe el paquete '$PACKAGE' ($MANIFEST)"
if [ ! -f "$MANIFEST" ]; then
echo "No existe el paquete '$PACKAGE' ($MANIFEST)" >&2
exit 1
# --- eleccion de incremento ---------------------------------------------------
if [ "${#POSITIONAL[@]}" -ge 2 ]; then
TYPE="${POSITIONAL[1]}"
else
while :; do
printf 'Incremento para %s (actual v%s):\n 1) patch\n 2) minor\n 3) major\n' "$PACKAGE" "$(version_of "$PACKAGE")" >&2
ask 'Elegir (numero o nombre): '; R="$ANSWER"
case "$R" in
1|patch) TYPE=patch; break ;;
2|minor) TYPE=minor; break ;;
3|major) TYPE=major; break ;;
esac
echo " opcion invalida" >&2
done
fi
case "$TYPE" in
patch|minor|major) ;;
*) echo "Incremento inválido: '$TYPE' (usa patch|minor|major)" >&2; exit 1 ;;
*) die "Incremento invalido: '$TYPE' (usa patch|minor|major)" ;;
esac
VERSION="$(php -r '$d=json_decode(file_get_contents($argv[1]),true); echo $d["version"] ?? "";' "$MANIFEST")"
if [ -z "$VERSION" ]; then
echo "El manifiesto no declara 'version': $MANIFEST" >&2
exit 1
VERSION="$(version_of "$PACKAGE")"
[ -n "$VERSION" ] || die "El manifiesto no declara 'version': $MANIFEST"
NEW_VERSION="$(bump "$VERSION" "$TYPE")"
TAG="$PACKAGE-v$NEW_VERSION"
CURRENT_CONFIG=""
[ "$PACKAGE" = "commands" ] && CURRENT_CONFIG="$(grep -oP "const VERSION = '\K[^']+" "$CONFIG_PHP" || true)"
LAST_TAG="$(git -C "$ROOT" tag -l "$PACKAGE-v*" --sort=-v:refname | head -1 || true)"
if [ -n "$LAST_TAG" ]; then
CHANGES="$(git -C "$ROOT" log --oneline "$LAST_TAG"..HEAD -- "packages/$PACKAGE" || true)"
else
CHANGES="$(git -C "$ROOT" log --oneline HEAD -- "packages/$PACKAGE" || true)"
fi
NEW_VERSION="$(php -r '
$v = ltrim($argv[1], "vV");
$type = $argv[2];
$p = explode(".", preg_replace("/[-+].*/", "", $v));
$p = array_pad($p, 3, 0);
$p = array_map("intval", $p);
if ($type === "major") { $p[0]++; $p[1] = 0; $p[2] = 0; }
elseif ($type === "minor") { $p[1]++; $p[2] = 0; }
else { $p[2]++; }
echo implode(".", $p);
' "$VERSION" "$TYPE")"
# --- resumen y confirmacion ---------------------------------------------------
echo "Release: $PACKAGE v$VERSION -> v$NEW_VERSION (tag $TAG)"
if [ "$PACKAGE" = "commands" ] && [ "$CURRENT_CONFIG" != "$NEW_VERSION" ]; then
echo " Config::VERSION: v$CURRENT_CONFIG -> v$NEW_VERSION (se sincroniza)"
fi
if [ -z "$CHANGES" ]; then
echo "Cambios desde el ultimo release: ninguno"
else
echo "Cambios desde el ultimo release:"
echo "$CHANGES" | sed 's/^/ /'
fi
confirm "Confirmar release?" || die "Cancelado; sin cambios."
# --- aplicar -------------------------------------------------------------------
php -r '
$file = $argv[1];
$data = json_decode(file_get_contents($file), true);
@@ -48,14 +144,23 @@ $data["version"] = $argv[2];
file_put_contents($file, json_encode($data, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES) . "\n");
' "$MANIFEST" "$NEW_VERSION"
TAG="$PACKAGE-v$NEW_VERSION"
git -C "$ROOT" add -- "$MANIFEST"
git -C "$ROOT" commit -q -m "release($PACKAGE): v$NEW_VERSION"
git -C "$ROOT" tag -a "$TAG" -m "release($PACKAGE): v$NEW_VERSION"
if git -C "$ROOT" remote get-url origin >/dev/null 2>&1; then
git -C "$ROOT" push -q origin HEAD --follow-tags
if [ "$PACKAGE" = "commands" ] && [ "$CURRENT_CONFIG" != "$NEW_VERSION" ]; then
sed -i "s/const VERSION = '[^']*'/const VERSION = '$NEW_VERSION'/" "$CONFIG_PHP"
fi
echo "release $PACKAGE v$NEW_VERSION ($TAG)"
git -C "$ROOT" add -- "$MANIFEST"
[ "$PACKAGE" = "commands" ] && git -C "$ROOT" add -- "$CONFIG_PHP"
git -C "$ROOT" commit -q -m "release($PACKAGE): v$NEW_VERSION"
git -C "$ROOT" tag -a "$TAG" -m "release($PACKAGE): v$NEW_VERSION"
echo "OK: commit + tag $TAG creados."
if git -C "$ROOT" remote get-url origin >/dev/null 2>&1; then
if confirm "Pushear a origin (commit + tag)?"; then
git -C "$ROOT" push -q origin HEAD --follow-tags
echo "OK: pusheado."
else
echo "Sin pushear. Cuando quieras: git push origin HEAD --follow-tags"
fi
else
echo "No hay remote origin; el tag queda local."
fi
Vendored Executable
+18
View File
@@ -0,0 +1,18 @@
#!/usr/bin/env bash
# Smoke HTTP del paquete cache (APCu sólo existe en SAPI web).
set -u
check() {
local comp="$1" path="$2" expect="$3" body
if ! body="$(curl -fsS "$BASE$path" 2>/dev/null)"; then
echo "$PKG:$comp|fail" >> "$RESULTS"; return
fi
if printf '%s' "$body" | grep -qF "$expect"; then
echo "$PKG:$comp|ok" >> "$RESULTS"
else
echo " inesperado en $path: $(printf '%s' "$body" | head -c 200)" >&2
echo "$PKG:$comp|fail" >> "$RESULTS"
fi
}
check apcache /compat/cache/apcache 'APC_OK'
+15
View File
@@ -0,0 +1,15 @@
<?php
// Router de compatibilidad del paquete cache (APCu via SAPI web).
use Libs\APCache;
use Libs\Router;
Router::get('/compat/cache/apcache', function () {
$key = 'compat_' . bin2hex(random_bytes(4));
$ok = APCache::save($key, 'valor-42', 30) !== false
&& APCache::get($key) === 'valor-42';
APCache::delete($key);
$ok = $ok && APCache::get($key) === false;
echo $ok ? 'APC_OK' : 'APC_FAIL';
});
+50
View File
@@ -0,0 +1,50 @@
#!/usr/bin/env bash
# Smoke commands:migration sobre sqlite (flujo CLI real del proyecto).
# Recibe BASE, PROJ, PKG, LOG, RESULTS.
set -u
res() { echo "$PKG:$1|$2" >> "$RESULTS"; }
mkdir -p "$PROJ/migrations"
cat > "$PROJ/migrations/2026_01_01_000000_compat_demo.php" <<'PHP'
<?php
use Libs\Migration;
return new class () extends Migration {
public function up(): void
{
$this->create('compat_table', [
'id INTEGER PRIMARY KEY AUTOINCREMENT',
$this->string('name'),
]);
}
public function down(): void
{
$this->dropTable('compat_table');
}
};
PHP
if ! (cd "$PROJ" && php duckbrain migration migrate >"$PROJ/../migrate.out" 2>&1); then
echo " migrate fallo:" >&2
sed 's/^/ /' "$PROJ/../migrate.out" >&2
res migration fail
exit 0
fi
PROJ_PATH="$PROJ" php -r '
$proj = getenv("PROJ_PATH");
$db = new PDO("sqlite:$proj/app.sqlite");
$table = (bool) $db->query("SELECT name FROM sqlite_master WHERE type=" . chr(39) . "table" . chr(39) . " AND name=" . chr(39) . "compat_table" . chr(39))->fetchColumn();
$ledger = glob("$proj/migrations/migrations-*.sqlite")[0] ?? null;
$done = 0;
if ($ledger !== null) {
$l = new PDO("sqlite:" . $ledger);
$done = (int) $l->query("SELECT COUNT(*) FROM migrations")->fetchColumn();
}
exit($table && $done === 1 ? 0 : 1);' \
&& res migration ok \
|| { echo " compat_table o registro de migracion ausentes" >&2; res migration fail; }
+20
View File
@@ -0,0 +1,20 @@
#!/usr/bin/env bash
# Smoke HTTP del paquete crypto. Recibe BASE, PKG, RESULTS.
set -u
check() {
local comp="$1" path="$2" expect="$3" body
if ! body="$(curl -fsS "$BASE$path" 2>/dev/null)"; then
echo "$PKG:$comp|fail" >> "$RESULTS"; return
fi
if printf '%s' "$body" | grep -qF "$expect"; then
echo "$PKG:$comp|ok" >> "$RESULTS"
else
echo " inesperado en $path: $(printf '%s' "$body" | head -c 200)" >&2
echo "$PKG:$comp|fail" >> "$RESULTS"
fi
}
check crypto /compat/crypto/roundtrip 'CRYPTO_OK'
check nonce /compat/crypto/nonce 'NONCE_OK'
check twoFactor /compat/crypto/2fa 'TFA_OK'
+30
View File
@@ -0,0 +1,30 @@
<?php
// Router de compatibilidad del paquete crypto.
use Libs\Crypto;
use Libs\Nonce;
use Libs\Router;
use Libs\TwoFactorAuthenticator;
Router::get('/compat/crypto/roundtrip', function () {
$plain = 'secreto: 42 ✓';
$hex = Crypto::encryptHex($plain);
$b64 = Crypto::encrypt64($plain);
$ok = Crypto::decryptHex($hex) === $plain
&& Crypto::decrypt64($b64) === $plain
&& Crypto::decrypt(Crypto::encrypt($plain)) === $plain;
echo $ok ? 'CRYPTO_OK' : 'CRYPTO_MISMATCH';
});
Router::get('/compat/crypto/nonce', function () {
$n = Nonce::get('compat');
echo (Nonce::check($n, 'compat') && !Nonce::check($n . 'x', 'compat')) ? 'NONCE_OK' : 'NONCE_FAIL';
});
Router::get('/compat/crypto/2fa', function () {
$tfa = new TwoFactorAuthenticator();
$secret = $tfa->createSecret(16);
$code = $tfa->getCode($secret);
echo ($tfa->verifyCode($secret, $code, 0) && !$tfa->verifyCode($secret, '000000', 0)) ? 'TFA_OK' : 'TFA_FAIL';
});
+19
View File
@@ -0,0 +1,19 @@
#!/usr/bin/env bash
# Smoke HTTP del paquete htmx. Recibe BASE, PKG, RESULTS.
set -u
check() {
local comp="$1" path="$2" expect="$3" body
if ! body="$(curl -fsS "$BASE$path" 2>/dev/null)"; then
echo "$PKG:$comp|fail" >> "$RESULTS"; return
fi
if printf '%s' "$body" | grep -qF "$expect"; then
echo "$PKG:$comp|ok" >> "$RESULTS"
else
echo " inesperado en $path: $(printf '%s' "$body" | head -c 200)" >&2
echo "$PKG:$comp|fail" >> "$RESULTS"
fi
}
check htmlComponent /compat/htmx/render '<h1 id="compat-greet">A&lt;b&gt;</h1>'
check htmx /compat/htmx/static 'HTMX:false:true'
+33
View File
@@ -0,0 +1,33 @@
<?php
// Router de compatibilidad del paquete htmx: monta un componente propio sobre
// el motor HTMLComponent/HTMX con el arranque real del core.
use Libs\HTMLComponent;
use Libs\HTMX;
use Libs\Router;
$views = ROOT_CORE . '/Views';
if (!is_dir($views)) {
mkdir($views, 0777, true);
}
file_put_contents($views . '/CompatGreet.php', <<<'VIEW'
<h1 id="compat-greet"><?php echo $this->escapedProperty('name'); ?></h1>
VIEW);
file_put_contents($views . '/CompatButton.php', <<<'VIEW'
<button hx-post="/compat/htmx/echo" hx-target="#compat-greet" class="btn">ir</button>
VIEW);
Router::get('/compat/htmx/render', function () {
echo 'GREET:';
HTMLComponent::render('CompatGreet', ['name' => 'A<b>']);
});
Router::get('/compat/htmx/static', function () {
$instance = new HTMX([], '<i hx-get="/x">ok</i>');
header('X-Marker: ' . (HTMX::isHtmx() ? 'yes' : 'no'));
echo 'HTMX:' . var_export(HTMX::isPartialRequest(), true)
. ':' . var_export($instance->getContent() !== '', true);
});
+21
View File
@@ -0,0 +1,21 @@
#!/usr/bin/env bash
# Smoke del paquete http. Recibe BASE, PKG, RESULTS.
set -u
check() {
local comp="$1" path="$2" expect="$3" body
if ! body="$(curl -fsS "$BASE$path" 2>/dev/null)"; then
echo "$PKG:$comp|fail" >> "$RESULTS"; return
fi
if printf '%s' "$body" | grep -qF "$expect"; then
echo "$PKG:$comp|ok" >> "$RESULTS"
else
echo " inesperado en $path: $(printf '%s' "$body" | head -c 200)" >&2
echo "$PKG:$comp|fail" >> "$RESULTS"
fi
}
check curlRequest /compat/http/curl 'CURL_OK'
check aria2 '/compat/http/load?class=Aria2' 'LOAD_OK:Libs\Aria2'
check proxy '/compat/http/load?class=Proxy' 'LOAD_OK:Libs\Proxy'
check compreFace '/compat/http/load?class=CompreFace' 'LOAD_OK:Libs\CompreFace'
+36
View File
@@ -0,0 +1,36 @@
<?php
// Router de compatibilidad del paquete http: curlRequest con flujo completo
// (se llama a si mismo via el mismo servidor) y carga nivel 1 de los clientes
// con servicio externo.
use Libs\Aria2;
use Libs\CompreFace;
use Libs\CurlRequest;
use Libs\Proxy;
use Libs\Router;
Router::get('/compat/http/echo', function () {
echo 'ECHO:' . ($_GET['who'] ?? 'nadie');
});
Router::get('/compat/http/curl', function () {
$host = $_SERVER['HTTP_HOST'];
$req = new CurlRequest();
$req->get("http://$host/compat/http/echo?who=42");
echo str_contains($req->body(), 'ECHO:42') ? 'CURL_OK' : 'CURL_FAIL:' . substr($req->body(), 0, 80);
});
Router::get('/compat/http/load', function () {
$class = 'Libs\\' . basename((string) ($_GET['class'] ?? ''));
if (!class_exists($class)) {
echo 'LOAD_MISSING';
return;
}
$instance = match ($class) {
'Libs\\Aria2' => new Aria2('http://127.0.0.1:6800/jsonrpc'),
'Libs\\CompreFace' => new CompreFace('compat-key', 'http://127.0.0.1:18080'),
default => new $class(),
};
echo 'LOAD_OK:' . get_class($instance);
});
+18
View File
@@ -0,0 +1,18 @@
#!/usr/bin/env bash
# Smoke HTTP del paquete id. Recibe BASE, PKG, RESULTS.
set -u
check() {
local comp="$1" path="$2" expect="$3" body
if ! body="$(curl -fsS "$BASE$path" 2>/dev/null)"; then
echo "$PKG:$comp|fail" >> "$RESULTS"; return
fi
if printf '%s' "$body" | grep -qF "$expect"; then
echo "$PKG:$comp|ok" >> "$RESULTS"
else
echo " inesperado en $path: $(printf '%s' "$body" | head -c 200)" >&2
echo "$PKG:$comp|fail" >> "$RESULTS"
fi
}
check ulid /compat/id/ulid 'ULID_OK'
+13
View File
@@ -0,0 +1,13 @@
<?php
// Router de compatibilidad del paquete id.
use Libs\Router;
use Libs\Ulid;
Router::get('/compat/id/ulid', function () {
$a = Ulid::generate();
$b = Ulid::generate();
$ok = strlen($a) === 26 && Ulid::validate($a) && !Ulid::validate(str_repeat('0', 25)) && $a !== $b;
echo $ok ? 'ULID_OK' : 'ULID_FAIL';
});
+35
View File
@@ -0,0 +1,35 @@
{
"text": {
"filter": "full",
"replacement": "full"
},
"crypto": {
"crypto": "full",
"nonce": "full",
"twoFactor": "full"
},
"id": {
"ulid": "full"
},
"cache": {
"apcache": "full"
},
"htmx": {
"htmlComponent": "full",
"htmx": "full"
},
"http": {
"curlRequest": "full",
"aria2": "load",
"proxy": "load",
"compreFace": "load"
},
"commands": {
"migration": "sqlite",
"cli": { "level": "none", "reason": "es el CLI del instalador; no consume el core (evidencia: suite offline group7/8)" },
"toolbox": { "level": "none", "reason": "motor del instalador; no consume el core (evidencia: suite offline group7/8)" }
},
"test": {
"harness": "runner"
}
}
+79
View File
@@ -0,0 +1,79 @@
{
"core": {
"version": "0.1.0",
"commit": "3b6da45b019d0c232bb5b2f1dc158d2bf25c8e1b"
},
"generated": "2026-10-05T20:34:25Z",
"components": {
"text:filter": {
"status": "ok",
"level": "full"
},
"text:replacement": {
"status": "ok",
"level": "full"
},
"crypto:crypto": {
"status": "ok",
"level": "full"
},
"crypto:nonce": {
"status": "ok",
"level": "full"
},
"crypto:twoFactor": {
"status": "ok",
"level": "full"
},
"id:ulid": {
"status": "ok",
"level": "full"
},
"cache:apcache": {
"status": "ok",
"level": "full"
},
"htmx:htmlComponent": {
"status": "ok",
"level": "full"
},
"htmx:htmx": {
"status": "ok",
"level": "full"
},
"http:curlRequest": {
"status": "ok",
"level": "full"
},
"http:aria2": {
"status": "ok",
"level": "load"
},
"http:proxy": {
"status": "ok",
"level": "load"
},
"http:compreFace": {
"status": "ok",
"level": "load"
},
"commands:migration": {
"status": "ok",
"level": "sqlite"
},
"commands:cli": {
"status": "skipped",
"level": "none",
"reason": "es el CLI del instalador; no consume el core (evidencia: suite offline group7/8)"
},
"commands:toolbox": {
"status": "skipped",
"level": "none",
"reason": "motor del instalador; no consume el core (evidencia: suite offline group7/8)"
},
"test:harness": {
"status": "ok",
"level": "runner"
}
}
}
+29
View File
@@ -0,0 +1,29 @@
#!/usr/bin/env bash
# Smoke test:harness — runner real (phpunit via composer) en el proyecto temporal.
# Recibe PROJ, PKG, RESULTS.
set -u
res() { echo "$PKG:harness|$1" >> "$RESULTS"; }
cd "$PROJ" || { res fail; exit 0; }
if [ ! -f composer.json ]; then
printf '{"name":"duckbrain/compat-probe","require":{}}\n' > composer.json
fi
if ! COMPOSER_NO_INTERACTION=1 composer require --dev phpunit/phpunit:^11.0 fakerphp/faker \
--no-interaction >/dev/null 2>"$PROJ/../composer.err"; then
echo " composer require fallo:" >&2
sed 's/^/ /' "$PROJ/../composer.err" >&2
res fail
exit 0
fi
if ! ./vendor/bin/phpunit >/dev/null 2>"$PROJ/../phpunit.err"; then
echo " phpunit fallo:" >&2
sed 's/^/ /' "$PROJ/../phpunit.err" >&2
res fail
exit 0
fi
res ok
+20
View File
@@ -0,0 +1,20 @@
#!/usr/bin/env bash
# Smoke HTTP del paquete text. Recibe BASE, PROJ, PKG, LOG, RESULTS.
set -u
check() {
local comp="$1" path="$2" expect="$3" body
if ! body="$(curl -fsS "$BASE$path" 2>/dev/null)"; then
echo "$PKG:$comp|fail" >> "$RESULTS"
return
fi
if printf '%s' "$body" | grep -qF "$expect"; then
echo "$PKG:$comp|ok" >> "$RESULTS"
else
echo " respuesta inesperada en $path: $(printf '%s' "$body" | head -c 200)" >&2
echo "$PKG:$comp|fail" >> "$RESULTS"
fi
}
check filter /compat/text/filter 'HOLA MUNDIN'
check replacement /compat/text/replacement 'bar y food'
+28
View File
@@ -0,0 +1,28 @@
<?php
// Router de compatibilidad del paquete text (inyectado en src/Routers/Compat.php
// por scripts/compat-sweep). Ejercita Filter y Replacement sobre el arranque
// real del core (Loader -> Router::apply()).
use Libs\Filter;
use Libs\Replacement;
use Libs\Router;
Router::get('/compat/text/filter', function () {
Filter::add(
fn (string $content): string => str_replace('mundo', 'MUNDIN', $content),
'compat',
5
);
Filter::add(
fn (string $content): string => strtoupper($content) . '|',
'compat',
20
);
echo 'FILTER_OK:' . Filter::apply('hola mundo', 'compat');
});
Router::get('/compat/text/replacement', function () {
Replacement::add('/\bfoo\b/', 'bar');
echo 'REPL_OK:' . Replacement::apply('foo y food y foo');
});
+75
View File
@@ -0,0 +1,75 @@
<?php
use Toolbox\Catalog;
Harness::suite('group11-compat');
$GLOBALS['g11_repo'] = dirname(__DIR__);
/**
* Contratos:
* - levels.json debe cubrir exactamente los componentes del catalogo.
* - si tests/compat/report.json existe, todo 'tested' declarado debe estar
* respaldado por un 'ok' del reporte, y los niveles 'none' no deben
* declarar tested.
* Sin reporte (entorno offline sin haber corrido el barrido) solo se exige el
* primer contrato.
*/
Harness::it('levels.json cubre exactamente los componentes del catalogo', function () {
$repo = $GLOBALS['g11_repo'];
$levels = json_decode((string) file_get_contents($repo . '/tests/compat/levels.json'), true);
ok(is_array($levels), 'levels.json debe ser un objeto por paquete');
$catalog = Catalog::fromRoot($repo);
$declared = [];
foreach ($catalog->packages() as $manifest) {
foreach (array_keys($manifest->components()) as $component) {
$declared[] = $manifest->name . ':' . $component;
}
}
$planned = [];
foreach ($levels as $package => $components) {
foreach (array_keys($components) as $component) {
$planned[] = $package . ':' . $component;
}
}
sort($declared);
sort($planned);
eq($declared, $planned, 'cada componente del catalogo tiene su nivel de evidencia planeado');
});
Harness::it('los stamps tested estan respaldados por el reporte del barrido', function () {
$repo = $GLOBALS['g11_repo'];
$reportPath = $repo . '/tests/compat/report.json';
if (!is_file($reportPath)) {
return; // offline sin barrido previo: no se inventa evidencia ni se exige.
}
$report = json_decode((string) file_get_contents($reportPath), true);
ok(is_array($report) && isset($report['components']), 'report.json valido');
$levels = json_decode((string) file_get_contents($repo . '/tests/compat/levels.json'), true);
$catalog = Catalog::fromRoot($repo);
foreach ($catalog->packages() as $manifest) {
foreach (array_keys($manifest->components()) as $component) {
$id = $manifest->name . ':' . $component;
$level = $levels[$manifest->name][$component] ?? null;
$level = is_array($level) ? $level['level'] : $level;
$status = $report['components'][$id]['status'] ?? 'missing';
$tested = $manifest->testedCore($component);
if ($level === 'none') {
eq(null, $tested, "$id esta deliberadamente sin datos y no debe declarar tested");
continue;
}
if ($tested !== null) {
eq('ok', $status, "$id declara tested='$tested' pero su evidencia en el reporte es '$status'");
}
}
}
});
+25
View File
@@ -121,3 +121,28 @@ Harness::it('ciclo de dependencias se detecta', function () {
rrmdir($dir);
});
Harness::it('la descripcion del componente prevalece y hereda del paquete', function () {
$m = Manifest::fromArray([
'name' => 'pkg',
'version' => '1.0.0',
'description' => 'Resumen del paquete',
'components' => [
'a' => ['files' => ['src/A.php'], 'description' => 'Componente A'],
'b' => ['files' => ['src/B.php']],
],
]);
eq('Componente A', $m->descriptionOf('a'), 'descripción propia gana');
eq('Resumen del paquete', $m->descriptionOf('b'), 'sin descripción propia hereda del paquete');
eq('Resumen del paquete', $m->descriptionOf(), 'sin componente: la del paquete');
$m2 = Manifest::fromArray(['name' => 'x', 'version' => '1.0.0', 'components' => ['c' => ['files' => ['c.php']]]]);
eq('', $m2->descriptionOf('c'), 'paquete sin texto produce cadena vacía (no null)');
throws(fn () => Manifest::fromArray([
'name' => 'y',
'version' => '1.0.0',
'components' => ['c' => ['files' => ['c.php'], 'description' => 7]],
]), 'description no-string es inválida');
});
+1 -1
View File
@@ -16,7 +16,7 @@ Harness::it('release bumpea, commitea, tagea y publica', function () {
seed_package($repo, '1.2.3');
git_commit($repo, 'init');
exec('cd ' . escapeshellarg($repo) . ' && bash scripts/release http minor', $out, $code);
exec('cd ' . escapeshellarg($repo) . ' && bash scripts/release http minor --yes', $out, $code);
eq(0, $code, 'release debe terminar bien: ' . implode("\n", $out));
$pushed = exec('git -C ' . escapeshellarg($repo) . ' tag -l "http-v1.3.0"');
+24
View File
@@ -238,3 +238,27 @@ Harness::it('--force resuelve drift pero no salta avisos de compatibilidad', fun
rrmdir($proj);
rrmdir($tool);
});
Harness::it('el catálogo describe cada componente con su texto propio o heredado', function () {
$toolbox = g7_toolbox();
$manifest = json_decode((string) file_get_contents($toolbox . '/packages/http/duckbrain.json'), true);
$manifest['components']['curlRequest']['description'] = 'Cliente HTTP sobre cURL';
write_file($toolbox . '/packages/http/duckbrain.json', json_encode($manifest, JSON_PRETTY_PRINT));
$proj = tmpdir('proj');
$out = fopen('php://memory', 'rw');
$code = (new Toolbox\Commands\CatalogCommand(new Console($out)))->run(
['--root=' . $proj, '--toolbox=' . $toolbox]
);
rewind($out);
$text = (string) stream_get_contents($out);
eq(0, $code, 'catalog termina bien');
ok((bool) preg_match('/curlRequest.*Cliente HTTP sobre cURL/', $text), 'fila propia con su texto');
ok((bool) preg_match('/aria2.*Utilidades HTTP/', $text), 'fila sin texto hereda el del paquete');
ok((bool) preg_match('/compreFace.*Utilidades HTTP/', $text), 'vecino heredando igual');
ok(!preg_match('/aria2.*cURL/', $text), 'los textos no se mezclan entre filas');
rrmdir($toolbox);
rrmdir($proj);
});
+2 -2
View File
@@ -61,12 +61,12 @@ Harness::it('la actualización del motor surte efecto en la siguiente invocació
// Simula una actualización del motor reescribiendo su archivo en disco.
$config = (string) file_get_contents($proj . '/src/Toolbox/Config.php');
$config = str_replace("VERSION = '0.1.0'", "VERSION = '0.9.9'", $config);
$config = preg_replace("/VERSION = '[^']*'/", "VERSION = '0.9.9'", $config);
write_file($proj . '/src/Toolbox/Config.php', $config);
$after = trim((string) shell_exec('cd ' . escapeshellarg($proj) . ' && php duckbrain --version 2>&1'));
ok(str_contains($before, '0.1.0'), 'antes usa la versión vieja');
ok(str_contains($before, \Toolbox\Config::VERSION), 'antes usa la versión vieja');
ok(str_contains($after, '0.9.9'), 'después usa la versión nueva sin recompilar');
rrmdir($proj);