feat: central Divi release lock for the Divi tools

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-09-30 01:54:36 +02:00
co-authored by Claude Opus 5.5
parent cf3c5dfe76
commit adfe0adfc2
8 changed files with 334 additions and 18 deletions
@@ -0,0 +1,107 @@
<?php
declare(strict_types=1);
namespace LIT\MCP\Integrations\Divi\Release;
use Closure;
use LIT\MCP\Abilities\Ability;
use LIT\MCP\ChangeLog\ChangeLog;
use LIT\MCP\Integrations\Divi\Library\LibraryAccess;
use LIT\MCP\Integrations\Divi\ThemeBuilder\ThemeBuilderAccess;
use WP_Error;
defined('ABSPATH') || exit;
/**
* The central release lock of the Divi tools (spec §5.5): a writing tool of the groups Divi, Divi-Bibliothek or Divi
* Theme Builder is refused before it runs while Divi is not released (logged with result "refused"); a reading tool
* runs and gets the notice in warnings. Other tools that reach Divi (lit/content-create with divi) meet the adapter
* lock (ReleaseGate::assertWritable()).
*/
final class DiviToolGate
{
public const GROUPS = ['divi', LibraryAccess::GROUP, ThemeBuilderAccess::GROUP];
/** @var Closure(): array<string, string> */
private Closure $groups;
/**
* @param Closure(): array<string, string> $groups Ability name => integration ID (IntegrationRegistry::groupMap).
*/
public function __construct(Closure $groups, private ?ChangeLog $log = null)
{
$this->groups = $groups;
}
public function covers(Ability $ability): bool
{
return in_array(($this->groups)()[$ability->name()] ?? '', self::GROUPS, true);
}
public function refusal(Ability $ability): ?WP_Error
{
if (! $this->covers($ability) || self::readsOnly($ability)) {
return null;
}
$status = ReleaseGate::status();
if ($status->writable()) {
return null;
}
$this->log?->record(
$ability->name(),
'divi',
$status->version,
sprintf(
'Abgelehnt: %s ist %s.',
$status->versionLabel(),
$status->state === ReleaseState::Incompatible ? 'inkompatibel' : 'nicht freigegeben'
),
result: 'refused'
);
return $status->refusal();
}
public function annotate(Ability $ability, mixed $result): mixed
{
if (! is_array($result) || array_is_list($result) || ! $this->covers($ability) || ! self::readsOnly($ability)) {
return $result;
}
$status = ReleaseGate::status();
if ($status->writable()) {
return $result;
}
$warnings = is_array($result['warnings'] ?? null) ? array_values($result['warnings']) : [];
$warnings[] = $status->notice();
$result['warnings'] = $warnings;
return $result;
}
/**
* @param array<string, mixed>|null $schema
* @return array<string, mixed>|null
*/
public function outputSchema(Ability $ability, ?array $schema): ?array
{
if ($schema === null || ($schema['type'] ?? null) !== 'object' || ! $this->covers($ability)) {
return $schema;
}
$properties = is_array($schema['properties'] ?? null) ? $schema['properties'] : [];
$properties['warnings'] ??= ['type' => 'array', 'items' => ['type' => 'string']];
$schema['properties'] = $properties;
return $schema;
}
private static function readsOnly(Ability $ability): bool
{
return ($ability->annotations()['readonly'] ?? false) === true;
}
}