From 901968d0bd1ae163a813059d5690204a1630e413 Mon Sep 17 00:00:00 2001 From: kj Date: Tue, 6 Oct 2026 14:12:36 -0300 Subject: [PATCH] feat(compat): declare core independence with core:"none" (INDEPENDENT status, block inheritance, no warning) --- packages/commands/duckbrain.json | 72 ++++++++++--------- .../src/Toolbox/Commands/DoctorCommand.php | 12 ++-- .../commands/src/Toolbox/Compatibility.php | 17 +++-- packages/commands/src/Toolbox/Component.php | 44 +++++++++++- packages/commands/src/Toolbox/Installer.php | 5 +- packages/commands/src/Toolbox/Manifest.php | 41 ++++++++--- tests/group11_compat_stamps_test.php | 28 +++++--- tests/group2_manifest_test.php | 33 +++++++++ tests/group5_compat_test.php | 64 +++++++++++++++++ 9 files changed, 247 insertions(+), 69 deletions(-) diff --git a/packages/commands/duckbrain.json b/packages/commands/duckbrain.json index dbdf51f..ca7f961 100644 --- a/packages/commands/duckbrain.json +++ b/packages/commands/duckbrain.json @@ -1,38 +1,40 @@ { - "name": "commands", - "version": "0.2.2", - "description": "CLI y migraciones para Duckbrain", - "components": { - "cli": { - "files": [ - "duckbrain", - "src/Interfaces/CommandInterface.php" - ], - "description": "CLI del proyecto (binario duckbrain)" - }, - "migration": { - "files": [ - "src/Commands/Migration.php", - "src/Libs/Migration.php" - ], - "require": { - "commands:cli": "*" - }, - "core": { - "requires": ">=0.1", - "tested": "0.1" - }, - "description": "Migraciones de base de datos (mysql/pgsql/sqlite)" - }, - "toolbox": { - "files": [ - "src/Toolbox" - ], - "description": "Gestor de addons dentro del propio proyecto" - } + "name": "commands", + "version": "0.2.2", + "description": "CLI y migraciones para Duckbrain", + "components": { + "cli": { + "files": [ + "duckbrain", + "src/Interfaces/CommandInterface.php" + ], + "description": "CLI del proyecto (binario duckbrain)", + "core": "none" }, - "default": [ - "cli", - "migration" - ] + "migration": { + "files": [ + "src/Commands/Migration.php", + "src/Libs/Migration.php" + ], + "require": { + "commands:cli": "*" + }, + "core": { + "requires": ">=0.1", + "tested": "0.1" + }, + "description": "Migraciones de base de datos (mysql/pgsql/sqlite)" + }, + "toolbox": { + "files": [ + "src/Toolbox" + ], + "description": "Gestor de addons dentro del propio proyecto", + "core": "none" + } + }, + "default": [ + "cli", + "migration" + ] } diff --git a/packages/commands/src/Toolbox/Commands/DoctorCommand.php b/packages/commands/src/Toolbox/Commands/DoctorCommand.php index 8a7f2cb..84efa4e 100644 --- a/packages/commands/src/Toolbox/Commands/DoctorCommand.php +++ b/packages/commands/src/Toolbox/Commands/DoctorCommand.php @@ -33,17 +33,19 @@ final class DoctorCommand extends Command foreach ($lock->components() as $id => $entry) { [$package, $component] = array_pad(explode(':', $id, 2), 2, null); - $requires = null; - $tested = null; + $requires = null; + $tested = null; + $independent = false; if ($component !== null && $package !== null && $catalog->has($package)) { $manifest = $catalog->get($package); if ($manifest->hasComponent($component)) { - $requires = $manifest->coreRequirement($component); - $tested = $manifest->testedCore($component); + $requires = $manifest->coreRequirement($component); + $tested = $manifest->testedCore($component); + $independent = $manifest->coreIndependent($component); } } - $result = Compatibility::evaluateCore($coreVersion, $requires, $tested); + $result = Compatibility::evaluateCore($coreVersion, $requires, $tested, $independent); $rows[] = [ $id, diff --git a/packages/commands/src/Toolbox/Compatibility.php b/packages/commands/src/Toolbox/Compatibility.php index d5c5167..b078d57 100644 --- a/packages/commands/src/Toolbox/Compatibility.php +++ b/packages/commands/src/Toolbox/Compatibility.php @@ -12,6 +12,7 @@ final class Compatibility public const UNTESTED = 'untested'; public const INSUFFICIENT = 'insufficient'; public const UNKNOWN = 'unknown'; + public const INDEPENDENT = 'independent'; /** * @return array{status:string, message:string} @@ -19,8 +20,13 @@ final class Compatibility public static function evaluateCore( ?string $coreVersion, ?string $requires, - ?string $tested + ?string $tested, + bool $independent = false ): array { + if ($independent) { + return ['status' => self::INDEPENDENT, 'message' => 'independiente del core']; + } + if ($coreVersion === null) { return ['status' => self::UNKNOWN, 'message' => 'versión del core desconocida (sin tags)']; } @@ -68,10 +74,11 @@ final class Compatibility public static function label(string $status): string { return match ($status) { - self::OK => 'OK', - self::UNTESTED => 'NO PROBADO', - self::INSUFFICIENT => 'INSUFICIENTE', - default => 'SIN DATOS', + self::OK => 'OK', + self::UNTESTED => 'NO PROBADO', + self::INSUFFICIENT => 'INSUFICIENTE', + self::INDEPENDENT => 'INDEPENDIENTE', + default => 'SIN DATOS', }; } } diff --git a/packages/commands/src/Toolbox/Component.php b/packages/commands/src/Toolbox/Component.php index d1df145..37f967b 100644 --- a/packages/commands/src/Toolbox/Component.php +++ b/packages/commands/src/Toolbox/Component.php @@ -18,6 +18,8 @@ final class Component public readonly ?string $description = null, public readonly ?array $destinations = null, public readonly bool $override = false, + public readonly bool $coreDeclared = false, + public readonly ?bool $coreIndependent = null, ) { } @@ -61,15 +63,53 @@ final class Component $description = $data['description']; } + $coreDeclared = array_key_exists('core', $data); + $coreIndependent = null; + $coreRequires = null; + $coreTested = null; + if ($coreDeclared) { + [$coreRequires, $coreTested, $coreIndependent] = self::parseCore($data['core'], "el componente '{$name}'"); + } + return new self( name: $name, files: array_values($data['files']), requires: $requires, - coreRequires: $data['core']['requires'] ?? null, - coreTested: $data['core']['tested'] ?? null, + coreRequires: $coreRequires, + coreTested: $coreTested, description: $description, destinations: $destinations, override: (bool) ($data['override'] ?? false), + coreDeclared: $coreDeclared, + coreIndependent: $coreIndependent, ); } + + /** + * Bloque `core`: objeto de rango ({requires?, tested?}) o la cadena + * "none" (independencia afirmativa, excluyente con el rango). + * + * @return array{0: ?string, 1: ?string, 2: ?bool} + */ + public static function parseCore(mixed $core, string $origin): array + { + if ($core === 'none') { + return [null, null, true]; + } + + if (!is_array($core)) { + throw new ToolboxException("{$origin} tiene un 'core' inválido"); + } + + $requires = $core['requires'] ?? null; + $tested = $core['tested'] ?? null; + + foreach (['requires' => $requires, 'tested' => $tested] as $key => $value) { + if ($value !== null && !is_string($value)) { + throw new ToolboxException("{$origin} tiene un 'core.{$key}' inválido"); + } + } + + return [$requires, $tested, null]; + } } diff --git a/packages/commands/src/Toolbox/Installer.php b/packages/commands/src/Toolbox/Installer.php index 042c256..450cd87 100644 --- a/packages/commands/src/Toolbox/Installer.php +++ b/packages/commands/src/Toolbox/Installer.php @@ -111,10 +111,11 @@ final class Installer $compat = Compatibility::evaluateCore( $this->coreVersion(), $manifest->coreRequirement($componentName), - $manifest->testedCore($componentName) + $manifest->testedCore($componentName), + $manifest->coreIndependent($componentName) ); - if ($compat['status'] !== Compatibility::OK) { + if (!in_array($compat['status'], [Compatibility::OK, Compatibility::INDEPENDENT], true)) { $warnings[] = $id . ': ' . $compat['message']; $assumeYes = (bool) ($options['yes'] ?? false); if (!$assumeYes && !$this->console->confirm(" {$id}: {$compat['message']}. ¿Continuar?", false)) { diff --git a/packages/commands/src/Toolbox/Manifest.php b/packages/commands/src/Toolbox/Manifest.php index 207f91d..519f343 100644 --- a/packages/commands/src/Toolbox/Manifest.php +++ b/packages/commands/src/Toolbox/Manifest.php @@ -18,6 +18,7 @@ final class Manifest public readonly ?string $coreTested, public readonly ?string $php, public readonly array $postInstall, + public readonly ?bool $coreIndependent = null, ) { } @@ -83,16 +84,23 @@ final class Manifest $postInstall[] = $command; } + $coreRequires = $coreTested = null; + $coreIndependent = null; + if (array_key_exists('core', $data)) { + [$coreRequires, $coreTested, $coreIndependent] = Component::parseCore($data['core'], "el manifiesto '{$origin}'"); + } + return new self( name: $name, version: $version, description: is_string($data['description'] ?? null) ? $data['description'] : '', components: $components, default: $default, - coreRequires: $data['core']['requires'] ?? null, - coreTested: $data['core']['tested'] ?? null, + coreRequires: $coreRequires, + coreTested: $coreTested, php: is_string($data['php'] ?? null) ? $data['php'] : null, postInstall: $postInstall, + coreIndependent: $coreIndependent, ); } @@ -140,22 +148,37 @@ final class Manifest return $this->default; } - public function coreRequirement(?string $component = null): ?string + /** + * Bloque de core efectivo: si el componente declara `core` (rango o + * "none") ese bloque gana por completo; si no, hereda el del paquete. + * + * @return array{requires: ?string, tested: ?string, independent: ?bool} + */ + private function coreBlock(?string $component): array { if ($component !== null) { - return $this->component($component)->coreRequires ?? $this->coreRequires; + $c = $this->component($component); + if ($c->coreDeclared) { + return ['requires' => $c->coreRequires, 'tested' => $c->coreTested, 'independent' => $c->coreIndependent]; + } } - return $this->coreRequires; + return ['requires' => $this->coreRequires, 'tested' => $this->coreTested, 'independent' => $this->coreIndependent]; + } + + public function coreRequirement(?string $component = null): ?string + { + return $this->coreBlock($component)['requires']; } public function testedCore(?string $component = null): ?string { - if ($component !== null) { - return $this->component($component)->coreTested ?? $this->coreTested; - } + return $this->coreBlock($component)['tested']; + } - return $this->coreTested; + public function coreIndependent(?string $component = null): bool + { + return $this->coreBlock($component)['independent'] === true; } /** diff --git a/tests/group11_compat_stamps_test.php b/tests/group11_compat_stamps_test.php index a682f67..32b5b8a 100644 --- a/tests/group11_compat_stamps_test.php +++ b/tests/group11_compat_stamps_test.php @@ -9,9 +9,9 @@ $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. + * - si tests/compat/report.json existe: todo 'tested' declarado debe estar + * respaldado por un 'ok' del reporte, y el nivel 'none' de evidencia debe + * coincidir con la declaracion 'core: none' del manifiesto (bidireccional). * Sin reporte (entorno offline sin haber corrido el barrido) solo se exige el * primer contrato. */ @@ -51,19 +51,25 @@ Harness::it('los stamps tested estan respaldados por el reporte del barrido', fu $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); + $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); + $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); + $independ = $manifest->coreIndependent($component); if ($level === 'none') { - eq(null, $tested, "$id esta deliberadamente sin datos y no debe declarar tested"); + ok($independ, "$id esta sin evidencia de barrido y debe declarar core none"); + } + + if ($independ) { + eq('none', $level, "$id se declara independiente y su nivel debe ser none"); + eq(null, $tested, "$id independiente no puede declarar tested"); continue; } diff --git a/tests/group2_manifest_test.php b/tests/group2_manifest_test.php index 05025a6..7818d20 100644 --- a/tests/group2_manifest_test.php +++ b/tests/group2_manifest_test.php @@ -146,3 +146,36 @@ Harness::it('la descripcion del componente prevalece y hereda del paquete', func 'components' => ['c' => ['files' => ['c.php'], 'description' => 7]], ]), 'description no-string es inválida'); }); + +Harness::it('core admite "none" como bloque con herencia completa', function () { + $m = Manifest::fromArray([ + 'name' => 'pkg', + 'version' => '1.0.0', + 'core' => 'none', + 'components' => [ + 'a' => ['files' => ['src/A.php']], + 'b' => ['files' => ['src/B.php'], 'core' => ['requires' => '>=0.2', 'tested' => '0.2']], + ], + ]); + + ok($m->coreIndependent('a'), 'a hereda la independencia del paquete'); + ok(!$m->coreIndependent('b'), 'b sobreescribe el bloque con su rango'); + eq('>=0.2', $m->coreRequirement('b')); + eq('0.2', $m->testedCore('b')); + eq(null, $m->coreRequirement('a'), 'independiente no arrastra rangos'); + + $m2 = Manifest::fromArray([ + 'name' => 'pkg2', + 'version' => '1.0.0', + 'core' => ['requires' => '>=0.1'], + 'components' => ['c' => ['files' => ['src/C.php'], 'core' => 'none']], + ]); + ok($m2->coreIndependent('c'), 'componente declara none sobre rango de paquete'); + eq(null, $m2->coreRequirement('c'), 'el rango del paquete no se filtra al bloque declarado none'); + eq('>=0.1', $m2->coreRequirement()); + + throws(fn () => Manifest::fromArray([ + 'name' => 'bad', 'version' => '1.0.0', 'core' => 'otro', + 'components' => ['x' => ['files' => ['x.php']]], + ]), 'core string distinto de none es inválido'); +}); diff --git a/tests/group5_compat_test.php b/tests/group5_compat_test.php index f231b75..4430fa6 100644 --- a/tests/group5_compat_test.php +++ b/tests/group5_compat_test.php @@ -66,3 +66,67 @@ Harness::it('doctor no modifica lock ni archivos', function () { rrmdir($root); rrmdir($tool); }); + +Harness::it('core independiente: sin aviso en add incluso sin core conocido', function () { + $root = tmpdir('proj'); + $tool = tmpdir('tool'); + write_file($tool . '/packages/http/duckbrain.json', json_encode([ + 'name' => 'http', 'version' => '1.0.0', 'description' => 'HTTP', + 'core' => 'none', + 'components' => ['aria2' => ['files' => ['src/Libs/Aria2.php']]], + ])); + write_file($tool . '/packages/http/src/Libs/Aria2.php', "setCore(['version' => null, 'commit' => 'x', 'files' => []]); + $lock->save($root); + + $out = fopen('php://memory', 'rw'); + $inst = new Toolbox\Installer($root, $tool, new Console($out)); + $res = $inst->add(['http:aria2'], []); + rewind($out); + $text = (string) stream_get_contents($out); + + eq([], $res['warnings'], 'independiente no produce aviso'); + eq([], $res['aborted'], 'nada se aborta'); + ok(str_contains(implode(',', $res['installed']), 'http:aria2'), 'instala normal'); + ok(!str_contains($text, 'sin datos'), 'sin ruido en consola'); + + // control: mismo fixture sin declaración sí avisa + write_file($tool . '/packages/http/duckbrain.json', json_encode([ + 'name' => 'http', 'version' => '1.0.1', 'description' => 'HTTP', + 'components' => ['aria2' => ['files' => ['src/Libs/Aria2.php']]], + ])); + $out2 = fopen('php://memory', 'rw'); + $res2 = (new Toolbox\Installer($root, $tool, new Console($out2)))->add(['http:aria2'], ['yes' => true]); + ok($res2['warnings'] !== [], 'sin declaración sigue avisando'); + + rrmdir($root); + rrmdir($tool); +}); + +Harness::it('doctor muestra independiente sin rangos y sin fallo', function () { + $root = tmpdir('proj'); + $tool = tmpdir('tool'); + write_file($tool . '/packages/db/duckbrain.json', json_encode([ + 'name' => 'db', 'version' => '1.0.0', 'description' => 'DB', + 'components' => ['sql' => ['files' => ['src/Libs/Sql.php'], 'core' => 'none']], + ])); + write_file($tool . '/packages/db/src/Libs/Sql.php', "setCore(['version' => '1.5.0', 'commit' => 'c', 'files' => []]); + $lock->setComponent('db:sql', ['package' => 'db', 'version' => '1.0.0', 'mode' => 'copy', 'files' => []]); + $lock->save($root); + + $out = fopen('php://memory', 'rw'); + $code = (new DoctorCommand(new Console($out)))->run(['--root=' . $root, '--toolbox=' . $tool]); + rewind($out); + $text = (string) stream_get_contents($out); + + eq(0, $code); + ok(str_contains($text, 'INDEPENDIENTE'), 'estado independiente visible'); + rrmdir($root); + rrmdir($tool); +});